Changeset 15344
- Timestamp:
- 10/15/2026 02:49:54 PM (8 days ago)
- Location:
- sites/trunk
- Files:
-
- 8 added
- 23 edited
-
api.wordpress.org/public_html/dotorg/freescout/copies.php (added)
-
api.wordpress.org/public_html/dotorg/freescout/replace-copies.php (added)
-
api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php (modified) (2 diffs)
-
api.wordpress.org/public_html/dotorg/freescout/tests/class-copy-plan-test.php (added)
-
api.wordpress.org/public_html/dotorg/freescout/tests/class-email-row-test.php (added)
-
api.wordpress.org/public_html/dotorg/freescout/webhook.php (modified) (6 diffs)
-
api.wordpress.org/public_html/dotorg/helpscout/common.php (modified) (2 diffs)
-
environments/freescout/mock-api/index.php (modified) (1 diff)
-
environments/plugin-directory/bin/database-tables.sql (modified) (1 diff)
-
freescout.wordpress.net/AGENTS.md (modified) (1 diff)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php (modified) (1 diff)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ReplaceCopies.php (added)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php (modified) (1 diff)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Copies.php (added)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json (modified) (1 diff)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/CopiesTest.php (added)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php (modified) (4 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php (modified) (9 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/README.md (modified) (2 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php (modified) (6 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php (modified) (3 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/Services/PayloadTooLargeException.php (added)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/module.json (modified) (1 diff)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/tests/EventForwardingTest.php (modified) (3 diffs)
-
freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
sites/trunk/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php
r15261 r15344 50 50 function wp_strip_all_tags( string $text ): string { 51 51 return trim( (string) preg_replace( '/<[^>]*>/', '', $text ) ); 52 } 53 54 /** 55 * Makes a slug, like WordPress's sanitize_title(), for the plain titles the tests use. 56 * 57 * @param string $title Title. 58 * @return string 59 */ 60 function sanitize_title( string $title ): string { 61 return trim( (string) preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' ); 52 62 } 53 63 … … 105 115 106 116 require_once dirname( __DIR__ ) . '/common.php'; 117 require_once dirname( __DIR__ ) . '/copies.php'; -
sites/trunk/api.wordpress.org/public_html/dotorg/freescout/webhook.php
r15261 r15344 1 1 <?php 2 2 /** 3 * FreeScout webhook: records contributor stats for helpdesk activity .3 * FreeScout webhook: records contributor stats for helpdesk activity, and keeps WordPress.org's copy of conversations. 4 4 * 5 5 * @package WordPressdotorg\API\FreeScout … … 11 11 12 12 require __DIR__ . '/common.php'; 13 require __DIR__ . '/copies.php'; 13 14 14 15 /** … … 18 19 */ 19 20 const REPLY_EVENTS = array( 'conversation.user_replied', 'conversation.created_by_user' ); 21 22 /** 23 * Events counted, but not credited to the agent: a restore undoes a credited deletion, and notes are only sent for the 24 * copy. 25 * 26 * @var string[] 27 */ 28 const UNCREDITED_EVENTS = array( 'conversation.restored', 'conversation.note_added' ); 29 30 /** 31 * WordPress.org accounts automations write to FreeScout as, like upload confirmations; their events aren't credited. 32 * 33 * @var int[] 34 */ 35 const AUTOMATION_USER_IDS = array( 5911429 ); // wordpressdotorg. 20 36 21 37 /** … … 30 46 bump_stats_extra( 'freescout', $event ); 31 47 32 if ( empty( $request->agent->id ) ) {48 if ( empty( $request->agent->id ) || in_array( $event, UNCREDITED_EVENTS, true ) ) { 33 49 return; 34 50 } … … 36 52 $username = (string) ( $request->agent->wporg_username ?? '' ); 37 53 $wporg_user = $username ? get_user_by( 'login', $username ) : false; 38 $stat_user = $wporg_user ? $wporg_user->user_nicename : 'FS-' . (int) $request->agent->id; 39 $mailbox = get_mailbox_slug( $request ); 40 $fields = array( 'total' ); 54 if ( $wporg_user && in_array( (int) $wporg_user->ID, AUTOMATION_USER_IDS, true ) ) { 55 return; 56 } 57 58 $stat_user = $wporg_user ? $wporg_user->user_nicename : 'FS-' . (int) $request->agent->id; 59 $mailbox = get_mailbox_slug( $request ); 60 $fields = array( 'total' ); 41 61 42 62 if ( in_array( $event, REPLY_EVENTS, true ) ) { … … 53 73 } 54 74 55 contributor_stats( get_request( basename( __FILE__ ) ) ); 75 $request = get_request( basename( __FILE__ ) ); 76 77 // Before the stats, which an event sent again would count twice. FreeScout sends it again after a 503. 78 try { 79 $needs_all_threads = log_email( $request ); 80 } catch ( \Throwable $e ) { 81 trigger_error( 'FreeScout copy of conversation ' . (int) ( $request->conversation->id ?? 0 ) . ' failed: ' . $e->getMessage(), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.Security.EscapeOutput.OutputNotEscaped -- Logged, not shown. 82 status_header( 503 ); 83 exit; 84 } 56 85 57 86 header( 'Content-Type: application/json; charset=utf-8' ); 87 88 // FreeScout sends the event again at once, with all of the conversation's threads; that one counts. 89 if ( $needs_all_threads ) { 90 echo '{"threads":"all"}'; 91 exit; 92 } 93 94 contributor_stats( $request ); 95 58 96 echo '{}'; -
sites/trunk/api.wordpress.org/public_html/dotorg/helpscout/common.php
r14781 r15344 545 545 546 546 /** 547 * Whether FreeScout's copy replaced a HelpScout conversation's, naming it under `helpscout_conversation`. 548 * 549 * Work still done on it in HelpScout must not bring HelpScout's copy back. 550 * 551 * @param int $helpscout_id HelpScout conversation ID. 552 * @return bool 553 */ 554 function is_moved_to_freescout( $helpscout_id ) { 555 global $wpdb; 556 557 return (bool) $wpdb->get_var( 558 $wpdb->prepare( 559 'SELECT 1 FROM %i WHERE meta_key = %s AND meta_value = %s LIMIT 1', 560 "{$wpdb->base_prefix}helpscout_meta", 561 'helpscout_conversation', 562 (string) $helpscout_id 563 ) 564 ); 565 } 566 567 /** 547 568 * Keep a cached copy of the received emails in the database for querying. 548 569 * … … 558 579 559 580 if ( empty( $request->id ) ) { 581 return; 582 } 583 584 if ( is_moved_to_freescout( $request->id ) ) { 560 585 return; 561 586 } -
sites/trunk/environments/freescout/mock-api/index.php
r15289 r15344 89 89 } 90 90 91 if ( 'replace-copies.php' === $endpoint ) { 92 $copies = (array) ( $request->copies ?? array() ); 93 file_put_contents( 'php://stderr', sprintf( "[replace-copies] %d conversations\n", count( $copies ) ) ); 94 respond( 200, array( 'replaced' => count( $copies ) ) ); 95 } 96 91 97 /* 92 98 * What the real endpoints sent for obenland's WordPress.org account, whatever the sender, though searches are for the -
sites/trunk/environments/plugin-directory/bin/database-tables.sql
r15213 r15344 2 2 -- These tables exist outside WordPress on production but are needed locally. 3 3 4 -- WordPress.org's copy of helpdesk conversations, which api.wordpress.org's webhooks fill. 5 CREATE TABLE IF NOT EXISTS `wp_helpscout` ( 6 `id` bigint(20) unsigned NOT NULL, 7 `number` int(10) unsigned NOT NULL DEFAULT 0, 8 `user_id` bigint(20) unsigned NOT NULL DEFAULT 0, 9 `mailbox` varchar(50) NOT NULL DEFAULT '', 10 `status` varchar(20) NOT NULL DEFAULT '', 11 `email` varchar(255) NOT NULL DEFAULT '', 12 `subject` varchar(255) NOT NULL DEFAULT '', 13 `preview` text, 14 `created` varchar(19) NOT NULL DEFAULT '', 15 `closed` varchar(19) NOT NULL DEFAULT '', 16 `modified` varchar(19) NOT NULL DEFAULT '', 17 PRIMARY KEY (`id`) 18 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 19 20 -- As on production. 4 21 CREATE TABLE IF NOT EXISTS `wp_helpscout_meta` ( 5 `helpscout_id` bigint(20) unsigned NOT NULL, 6 `meta_key` varchar(255) NOT NULL DEFAULT '', 7 `meta_value` varchar(255) NOT NULL DEFAULT '', 8 KEY `helpscout_id` (`helpscout_id`), 9 KEY `meta_key_value` (`meta_key`(191),`meta_value`(191)) 10 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 22 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, 23 `helpscout_id` bigint(20) NOT NULL, 24 `meta_key` varchar(32) NOT NULL, 25 `meta_value` varchar(255) NOT NULL, 26 PRIMARY KEY (`id`), 27 UNIQUE KEY `helpscout_id` (`helpscout_id`,`meta_key`,`meta_value`), 28 KEY `meta_key` (`meta_key`,`meta_value`) 29 ) ENGINE=InnoDB DEFAULT CHARSET=latin1; 11 30 12 31 CREATE TABLE IF NOT EXISTS `wp_svn_access` ( -
sites/trunk/freescout.wordpress.net/AGENTS.md
r15289 r15344 11 11 - `Modules/<Name>/` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`). 12 12 - `WPOrgAkismet` — checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections. 13 - `WPOrgHelpScoutImport` — imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires . Administrators run it under Manage » HelpScout Import. Remove it once every mailbox has moved.13 - `WPOrgHelpScoutImport` — imports HelpScout mailboxes' conversations, with their tags and custom fields, and their saved replies, from HelpScout's API, page by page on the queue, without the events live email fires, and points WordPress.org's copy of conversations at the imported ones as each mailbox switches. Administrators run it under Manage » HelpScout Import. Remove it once every mailbox has moved. 14 14 - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. 15 15 - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far, it adds the WordPress mark, lists only installed modules on the Modules page, refuses to update or delete them there, and lays their cards out in columns. 16 16 - `WPOrgSSO` — logs agents in through login.wordpress.org's SAML identity provider (wp-saml-idp, in the private dotorg repository), and connects every user to a WordPress.org account. Its SAML library is committed in its `vendor/`: FreeScout doesn't install module dependencies. After changing its `composer.json`, run `composer install --no-dev` in the module and commit `vendor/` (`git add -f`: the root `.gitignore` ignores it). 17 - `WPOrgWebhooks` — queues conversation events to `api.wordpress.org/dotorg/freescout/webhook.php`, which records contributor stats .17 - `WPOrgWebhooks` — queues conversation events to `api.wordpress.org/dotorg/freescout/webhook.php`, which records contributor stats, and keeps the copy of conversations (`wporg_helpscout`) the plugin directory reads. 18 18 - `tests/` — shared PHPUnit bootstrap and base `TestCase`. 19 19 -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php
r15265 r15344 19 19 use Modules\WPOrgHelpScoutImport\Entities\Run; 20 20 use Modules\WPOrgHelpScoutImport\Jobs\ImportPage; 21 use Modules\WPOrgHelpScoutImport\Services\Copies; 21 22 use Modules\WPOrgHelpScoutImport\Services\HelpScout; 22 23 use Modules\WPOrgHelpScoutImport\Services\People; … … 83 84 // Imports bring saved replies only while there's somewhere to put them. 84 85 'replies' => SavedReplies::available(), 86 'copies' => self::copies(), 85 87 ) 86 88 ); 89 } 90 91 /** 92 * Switches a mailbox's conversations in WordPress.org's copy to FreeScout, or sends them again. 93 * 94 * @param Request $request Request. 95 * @return RedirectResponse 96 */ 97 public function point( Request $request ): RedirectResponse { 98 $mailbox = Mailbox::find( (int) $request->input( 'mailbox_id' ) ); 99 if ( ! $mailbox ) { 100 return self::back_with_error( __( 'Choose a FreeScout mailbox.' ) ); 101 } 102 103 if ( ! Copies::available() ) { 104 return self::back_with_error( __( 'Pointing WordPress.org at FreeScout needs the WP.org Webhooks module, set up with WPORG_API_SECRET.' ) ); 105 } 106 107 Copies::start( (int) $mailbox->id ); 108 109 return redirect() 110 ->route( 'wporghelpscoutimport.index' ) 111 ->with( 'flash_success', __( 'Pointing WordPress.org at :name’s imported conversations.', array( 'name' => $mailbox->name ) ) ); 112 } 113 114 /** 115 * The mailboxes conversations were imported into, and their switch. 116 * 117 * @return array[] Mailbox, total imported, and the switch, see Copies::get(). 118 */ 119 private static function copies(): array { 120 $copies = array(); 121 $ids = Run::query()->distinct()->pluck( 'mailbox_id' )->map( 'intval' )->all(); 122 123 foreach ( Mailbox::query()->whereIn( 'id', $ids ? $ids : array( 0 ) )->orderBy( 'name' )->get() as $mailbox ) { 124 $copies[] = array( 125 'mailbox' => $mailbox, 126 'total' => Copies::total( (int) $mailbox->id ), 127 'state' => Copies::get( (int) $mailbox->id ), 128 ); 129 } 130 131 return $copies; 87 132 } 88 133 -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php
r15265 r15344 23 23 Route::get( '/helpscout-import/users/export', 'AgentsController@export' )->name( 'wporghelpscoutimport.agents.export' ); 24 24 Route::post( '/helpscout-import/users/connect', 'AgentsController@connect' )->name( 'wporghelpscoutimport.agents.connect' ); 25 Route::post( '/helpscout-import/copies', 'ImportController@point' )->name( 'wporghelpscoutimport.copies' ); 25 26 Route::post( '/helpscout-import/{id}/pause', 'ImportController@pause' )->name( 'wporghelpscoutimport.pause' ); 26 27 Route::post( '/helpscout-import/{id}/resume', 'ImportController@resume' )->name( 'wporghelpscoutimport.resume' ); -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php
r15285 r15344 18 18 use Modules\WPOrgHelpScoutImport\Exceptions\ApiError; 19 19 use Modules\WPOrgHelpScoutImport\Exceptions\RateLimited; 20 use Modules\WPOrgHelpScoutImport\Services\Copies; 20 21 use Modules\WPOrgHelpScoutImport\Services\HelpScout; 21 22 use Modules\WPOrgHelpScoutImport\Services\Importer; … … 261 262 $run->status = Run::STATUS_DONE; 262 263 $run->finished_at = Carbon::now(); 263 $this->save( $run ); 264 if ( $this->save( $run ) ) { 265 // Send what this run imported, if the mailbox switched. 266 Copies::send_again( (int) $run->mailbox_id ); 267 } 264 268 265 269 return; -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php
r15265 r15344 10 10 namespace Modules\WPOrgHelpScoutImport\Providers; 11 11 12 use App\Conversation; 12 13 use Illuminate\Support\ServiceProvider; 14 use Modules\WPOrgHelpScoutImport\Entities\ImportedConversation; 15 use Modules\WPOrgHelpScoutImport\Services\Copies; 13 16 use Modules\WPOrgHelpScoutImport\Services\HelpScout; 14 17 … … 120 123 ); 121 124 125 // Names the HelpScout conversation whose copy an imported one's replaces. 126 \Eventy::addFilter( 127 'wporgwebhooks.helpscout_id', 128 static function ( mixed $helpscout_id = 0, mixed $conversation = null, mixed $conversation_id = 0 ): mixed { 129 // Deleted for good, the conversation is null, but its ID still finds the import. 130 $id = $conversation instanceof Conversation ? (int) $conversation->id : (int) $conversation_id; 131 132 try { 133 if ( ! $helpscout_id && $id ) { 134 $helpscout_id = (int) ImportedConversation::query()->where( 'conversation_id', $id )->value( 'helpscout_id' ); 135 } 136 } catch ( \Throwable $e ) { 137 \Log::error( '[WPOrgHelpScoutImport] Could not find the HelpScout conversation of ' . $id . ': ' . $e->getMessage() ); 138 } 139 140 return $helpscout_id; 141 }, 142 10, 143 3 144 ); 145 146 // Imported conversations' events leave the copy alone until their mailbox switches. 147 \Eventy::addFilter( 148 'wporgwebhooks.copy', 149 static function ( mixed $copy = true, mixed $conversation = null, mixed $conversation_id = 0, mixed $mailbox_id = 0 ): mixed { 150 $id = $conversation instanceof Conversation ? (int) $conversation->id : (int) $conversation_id; 151 $mailbox = $conversation instanceof Conversation ? (int) $conversation->mailbox_id : (int) $mailbox_id; 152 153 try { 154 return $copy && ( ! $id || Copies::sends( $id, $mailbox ) ); 155 } catch ( \Throwable $e ) { 156 \Log::error( '[WPOrgHelpScoutImport] Could not tell whether to copy conversation ' . $id . ': ' . $e->getMessage() ); 157 158 return $copy; 159 } 160 }, 161 10, 162 4 163 ); 164 122 165 // Highlights Manage while the pages are open. 123 166 \Eventy::addFilter( -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md
r15287 r15344 40 40 - What HelpScout did itself, without a user, is credited to "HelpScout Import", a disabled robot user. The Reports module lists it among the users it reports on, for what it's credited with. 41 41 - **Teams:** conversations assigned to a HelpScout team are assigned to the FreeScout team chosen on the Users page, or else to the one with the same name. FreeScout's teams come from its Teams module: create them there first. Without either, they're imported unassigned, and choosing a team later, or the next import finding one with the same name, assigns them. The Users page flags teams without access to the mailboxes their conversations are in. 42 - **Nothing reacts to it, except scheduled workflows:** conversations are marked as imported, and written without the events new email fires. Nothing is sent, nobody is notified, workflows aren't triggered, and `WPOrgAkismet` and `WPOrgWebhooks` leavethem alone. New senders aren't announced either, like to the API & Webhooks module's webhooks.42 - **Nothing reacts to it, except scheduled workflows:** conversations are marked as imported, and written without the events new email fires. Nothing is sent, nobody is notified, workflows aren't triggered, and `WPOrgAkismet` leaves them alone. New senders aren't announced either, like to the API & Webhooks module's webhooks. 43 43 - The Workflows module also runs automatic workflows every hour, over the conversations they apply to: all of them with **Apply to previous conversations**, and with a date condition, like how long it's been waiting since the last reply, every conversation created after the workflow. Imported conversations keep HelpScout's dates, so those workflows act on them as on any other: they can email senders, close conversations, or delete them for good. The import can't stop them. See Setup. 44 44 - The same goes for conversations still being worked on in HelpScout, while both run. … … 57 57 - Conversation numbers: under Manage » Settings » General, set **Conversation Number** to **Custom…** (`APP_CUSTOM_NUMBER=true`), or FreeScout shows and searches its internal IDs instead. Before the first import, set the **Next Conversation #** that appears well above HelpScout's numbers, like 2,000,000: HelpScout keeps numbering the mailboxes that haven't moved yet, and conversations whose number FreeScout has given away already keep a number of FreeScout's. The import page warns until both are set, and doesn't start an import before the Next Conversation # is. Imports leave that setting alone. 58 58 - Teams: install the Teams module, and create the teams, before importing mailboxes with conversations assigned to teams. Tick the mailboxes each team works in on its page: a team only has a folder, and can be assigned again, in those, even when the import assigns it a conversation elsewhere. 59 - **WordPress.org:** WordPress.org keeps a copy of conversations, which the plugin directory reads. It points at HelpScout until a mailbox switches. To switch one, in this order: 60 1. In HelpScout, stop the mailbox's events going to `api.wordpress.org/dotorg/helpscout/webhook.php`, its webhook under **Manage » Apps**: scope it to the mailboxes still in HelpScout, or turn it off once none is left. While it runs for the mailbox, anything done there writes HelpScout's copy of a conversation back, and counts in HelpScout's stats. It leaves alone the HelpScout conversations whose copies FreeScout's replaced, as each copy names the one it replaced, but not the others. 61 2. Run the last import of the mailbox. 62 3. Together with moving the mailbox's email to FreeScout, press **Point WordPress.org at FreeScout** for it, under **WordPress.org** on the import page. That sends WordPress.org which FreeScout conversation each HelpScout one was imported as, 250 at a time, on the queue (`api.wordpress.org/dotorg/freescout/replace-copies.php`), without importing anything again. The copies of conversations in FreeScout's Deleted folder, or marked spam, are deleted instead. The import page shows how many were sent, and when it's done. If WordPress.org doesn't take a batch, it says why, and **Send again** sends them all again; what was sent already doesn't change. 63 4. After the cutover, import the mailbox again, for what HelpScout got in between. Every import that finishes after the switch sends all of the mailbox's imported conversations again. 64 65 Until a mailbox switches, `WPOrgWebhooks` sends imported conversations' events for stats, but not the conversations, so WordPress.org goes on pointing at HelpScout; after, it sends them, naming the HelpScout conversation each replaces. 66 67 HelpScout conversations that were never imported, or never replaced, like those HelpScout deleted, or those deleted for good in FreeScout before the switch, keep HelpScout's copies, which have no `helpdesk` meta. Clean those up in WordPress.org's copy before HelpScout closes. 59 68 - Saved replies, tags, and custom fields: switch on the Saved Replies, Tags, and Custom Fields modules before importing. 60 69 - Workflows: before importing, give every automatic workflow with **Apply to previous conversations** or a date condition the condition **Imported** is **No**, unless it's meant for HelpScout's history too, and the same to those added later. It goes by the conversation's latest thread, so the workflow applies to a conversation again once agents add a reply, note, or change in FreeScout. Or keep the Workflows module off until after the last import, and add the condition before switching it on. -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php
r15265 r15344 214 214 </table> 215 215 @endif 216 217 @if ( count( $copies ) ) 218 <h3 class="margin-top">{{ __('WordPress.org') }}</h3> 219 <p class="text-help">{{ __('WordPress.org keeps a copy of conversations, which the plugin directory links to, and replies to uploads in. It points at HelpScout until a mailbox switches to FreeScout: once HelpScout’s webhook leaves the mailbox alone, and after its last import, point WordPress.org at the conversations imported from HelpScout, together with moving the mailbox’s email. Until then, what happens to imported conversations in FreeScout isn’t sent to WordPress.org; after, it is, and every import that finishes sends them all again. Sending them again changes nothing. See the module’s README for every step.') }}</p> 220 <table class="table table-striped"> 221 <thead> 222 <tr> 223 <th>{{ __('Mailbox') }}</th> 224 <th>{{ __('Imported') }}</th> 225 <th>{{ __('WordPress.org points at') }}</th> 226 <th></th> 227 </tr> 228 </thead> 229 <tbody> 230 @foreach ( $copies as $copy ) 231 @php( $state = $copy['state'] ) 232 <tr> 233 <td>{{ $copy['mailbox']->name }}</td> 234 <td>{{ number_format( $copy['total'] ) }}</td> 235 <td> 236 @if ( ! $state ) 237 {{ __('HelpScout') }} 238 @else 239 {{ __('FreeScout, since :date', [ 'date' => App\User::dateFormat( $state['switched_at'], 'M j, Y H:i' ) ]) }} 240 <br/><small> 241 @if ( $state['finished_at'] ) 242 {{ __(':sent sent, done :date', [ 'sent' => number_format( $state['sent'] ), 'date' => App\User::dateFormat( $state['finished_at'], 'M j, Y H:i' ) ]) }} 243 @else 244 {{ __(':sent of :total sent so far', [ 'sent' => number_format( $state['sent'] ), 'total' => number_format( $copy['total'] ) ]) }} 245 @endif 246 </small> 247 @if ( '' !== $state['error'] ) 248 <br/><small class="text-danger">{{ $state['error'] }}</small> 249 @endif 250 @endif 251 </td> 252 <td> 253 <form method="POST" action="{{ route( 'wporghelpscoutimport.copies' ) }}"> 254 {{ csrf_field() }} 255 <input type="hidden" name="mailbox_id" value="{{ $copy['mailbox']->id }}"> 256 <button type="submit" class="btn btn-default btn-xs">{{ $state ? __('Send again') : __('Point WordPress.org at FreeScout') }}</button> 257 </form> 258 </td> 259 </tr> 260 @endforeach 261 </tbody> 262 </table> 263 @endif 216 264 </div> 217 265 </div> -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json
r15265 r15344 4 4 "img": "/modules/wporghelpscoutimport/img/icon.svg", 5 5 "description": "Imports HelpScout mailboxes' conversations, so their history moves to FreeScout with them.", 6 "version": "1. 0.0",6 "version": "1.1.0", 7 7 "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport", 8 8 "author": "WordPress.org", -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php
r15265 r15344 17 17 use Modules\WPOrgHelpScoutImport\Entities\Run; 18 18 use Modules\WPOrgHelpScoutImport\Jobs\ImportPage; 19 use Modules\WPOrgHelpScoutImport\Services\Copies; 19 20 20 21 require_once __DIR__ . '/ImportTestCase.php'; … … 303 304 304 305 /** 306 * The page shows how far pointing WordPress.org at a mailbox's imported conversations got, and why it stopped. 307 * 308 * @return void 309 */ 310 public function test_shows_how_far_wordpress_org_was_pointed(): void { 311 $this->post( route( 'wporghelpscoutimport.start' ), $this->start_form() ); 312 $mailbox_id = (int) Run::query()->firstOrFail()->mailbox_id; 313 Copies::start( $mailbox_id ); 314 315 $this->assertStringContainsString( '0 of 0 sent so far', $this->get( route( 'wporghelpscoutimport.index' ) )->getContent() ); 316 317 Copies::note( 318 $mailbox_id, 319 (string) Copies::get( $mailbox_id )['run'], 320 array( 321 'sent' => 3, 322 'finished_at' => '2026-10-02 10:00:00', 323 'error' => 'WordPress.org answered 503.', 324 ) 325 ); 326 $page = $this->get( route( 'wporghelpscoutimport.index' ) )->getContent(); 327 328 $this->assertStringContainsString( '3 sent, done', $page ); 329 $this->assertStringContainsString( 'WordPress.org answered 503.', $page ); 330 } 331 332 /** 305 333 * A run that hasn't saved progress for a while is shown stalled, and can be resumed. 306 334 * -
sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php
r15287 r15344 22 22 use Modules\WPOrgHelpScoutImport\Entities\ImportedThread; 23 23 use Modules\WPOrgHelpScoutImport\Entities\Person; 24 use Modules\WPOrgHelpScoutImport\Providers\WPOrgHelpScoutImportServiceProvider; 24 25 use Modules\WPOrgHelpScoutImport\Services\HelpScout; 25 26 use Modules\WPOrgHelpScoutImport\Services\Importer; … … 393 394 $this->assertSame( (int) $themes->id, (int) $this->imported_conversation()->mailbox_id ); 394 395 $this->assertSame( 0, (int) $themes->folders()->where( 'type', Folder::TYPE_UNASSIGNED )->value( 'active_count' ) ); 396 } 397 398 /** 399 * An imported conversation's events name its HelpScout conversation, for WordPress.org's copy. 400 * 401 * @return void 402 */ 403 public function test_names_the_helpscout_conversation_of_imported_ones(): void { 404 $this->app->register( WPOrgHelpScoutImportServiceProvider::class ); 405 $source = $this->conversation(); 406 $this->importer->import( $source, $this->mailbox ); 407 408 $this->assertSame( (int) $source['id'], \Eventy::filter( 'wporgwebhooks.helpscout_id', 0, $this->imported_conversation() ) ); 395 409 } 396 410 -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php
r15244 r15344 15 15 use Illuminate\Queue\InteractsWithQueue; 16 16 use Modules\WPOrgWebhooks\Services\Client; 17 use Modules\WPOrgWebhooks\Services\EventPayload; 17 18 use Modules\WPOrgWebhooks\Services\NotDeliveredException; 19 use Modules\WPOrgWebhooks\Services\PayloadTooLargeException; 18 20 19 21 /** … … 60 62 * Sends the event, and tries again later if api.wordpress.org didn't get it. 61 63 * 62 * Other failures aren't retried, since webhook.php may have counted the event already. They fail the job rather 63 * than throw: the worker would retry anything thrown until the job runs out of tries. 64 * If WordPress.org asks for all of the conversation's threads, it's sent again with them. Other failures aren't 65 * retried, since webhook.php may have counted the event already. They fail the job rather than throw: the worker 66 * would retry anything thrown until the job runs out of tries. 64 67 * 65 68 * @return void … … 67 70 public function handle(): void { 68 71 try { 69 Client::from_config()->post( (string) config( 'wporgwebhooks.endpoint' ), $this->payload ); 72 $client = Client::from_config(); 73 $response = self::send( $client, self::current( $this->payload ) ); 74 75 if ( 'all' === ( $response['threads'] ?? '' ) && empty( $this->payload['all_threads'] ) ) { 76 self::send( $client, self::current( array( 'all_threads' => true ) + $this->payload ) ); 77 } 70 78 } catch ( NotDeliveredException $e ) { 71 79 if ( $this->attempts() < $this->tries ) { … … 80 88 } 81 89 } 90 91 /** 92 * Posts an event; one too large for the web server goes again without its threads. 93 * 94 * @param Client $client Client. 95 * @param array $payload Event payload, as it's sent. 96 * @return array What webhook.php answered. 97 * 98 * @throws PayloadTooLargeException If it's too large without its threads too. 99 */ 100 private static function send( Client $client, array $payload ): array { 101 $endpoint = (string) config( 'wporgwebhooks.endpoint' ); 102 103 try { 104 return $client->post( $endpoint, $payload ); 105 } catch ( PayloadTooLargeException $e ) { 106 if ( empty( $payload['email']['threads'] ) ) { 107 throw $e; 108 } 109 110 \Log::error( '[WPOrgWebhooks] Sending conversation ' . (int) ( $payload['conversation']['id'] ?? 0 ) . '\'s event without its threads: ' . $e->getMessage() ); 111 $payload['email']['threads'] = array(); 112 113 return $client->post( $endpoint, $payload ); 114 } 115 } 116 117 /** 118 * The payload with the conversation as it is now; as built, if that can't be read. 119 * 120 * @param array $payload Event payload. 121 * @return array 122 */ 123 private static function current( array $payload ): array { 124 try { 125 return EventPayload::refresh( $payload ); 126 } catch ( \Throwable $e ) { 127 \Log::error( '[WPOrgWebhooks] Could not refresh an event: ' . $e->getMessage() ); 128 129 return $payload; 130 } 131 } 82 132 } -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php
r15259 r15344 92 92 $event, 93 93 static function () use ( $conversation, $thread ): ?array { 94 return self::imported( $thread ) || $conversation->isSpam() ? null : array( $conversation, null );94 return self::imported( $thread ) || $conversation->isSpam() ? null : array( $conversation, null, self::thread( $thread ) ); 95 95 } 96 96 ); … … 107 107 'conversation.created_by_user', 108 108 static function () use ( $conversation, $thread ): ?array { 109 return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user ) : null;109 return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user, self::thread( $thread ) ) : null; 110 110 } 111 111 ); … … 121 121 'conversation.user_replied', 122 122 static function () use ( $conversation, $thread ): ?array { 123 return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user ) : null;123 return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user, self::thread( $thread ) ) : null; 124 124 } 125 125 ); … … 129 129 ); 130 130 131 foreach ( array( 'conversation.user_changed', 'conversation.status_changed', 'conversation.moved' ) as $event ) { 131 // Leaving spam, the conversation is copied in full again. 132 \Eventy::addAction( 133 'conversation.status_changed', 134 static function ( $conversation = null, $user = null, $changed_on_reply = false, $prev_status = null ): void { 135 self::forward( 136 'conversation.status_changed', 137 static function () use ( $conversation, $user, $prev_status ): array { 138 $unspammed = Conversation::STATUS_SPAM === (int) $prev_status && $conversation instanceof Conversation && ! $conversation->isSpam(); 139 140 return array( $conversation, $user, $unspammed ? array( 'unspammed' => true ) : array() ); 141 } 142 ); 143 }, 144 20, 145 4 146 ); 147 148 // For the plugins and themes it mentions; not credited. 149 \Eventy::addAction( 150 'conversation.note_added', 151 static function ( $conversation = null, $thread = null ): void { 152 self::forward( 153 'conversation.note_added', 154 static function () use ( $conversation, $thread ): ?array { 155 return self::imported( $thread ) ? null : array( $conversation, $thread->created_by_user ?? null, self::thread( $thread ) ); 156 } 157 ); 158 }, 159 20, 160 2 161 ); 162 163 foreach ( array( 'conversation.user_changed', 'conversation.moved' ) as $event ) { 132 164 \Eventy::addAction( 133 165 $event, … … 150 182 self::forward( 151 183 'conversation.merged', 152 static function () use ( $conversation, $user ): array { 153 return array( $conversation, $user ); 184 static function () use ( $conversation, $second, $user ): array { 185 // WordPress.org folds the merged conversation's copy into this one. 186 return array( $conversation, $user, array( 'merged_id' => $second instanceof Conversation ? (int) $second->id : 0 ) ); 187 } 188 ); 189 }, 190 20, 191 3 192 ); 193 194 // The copy dropped the conversation when it was deleted. 195 \Eventy::addAction( 196 'conversation.state_changed', 197 static function ( $conversation = null, $user = null, $previous_state = null ): void { 198 self::forward( 199 'conversation.restored', 200 static function () use ( $conversation, $user, $previous_state ): ?array { 201 $restored = Conversation::STATE_DELETED === (int) $previous_state && Conversation::STATE_PUBLISHED === (int) $conversation->state; 202 203 return $restored ? array( $conversation, $user ) : null; 154 204 } 155 205 ); … … 199 249 200 250 /** 251 * The reply an event is about, so WordPress.org reads the threads up to it, however late the event is sent. 252 * 253 * @param Thread|null $thread Reply. 254 * @return array Empty without one. 255 */ 256 private static function thread( ?Thread $thread ): array { 257 return $thread ? array( 'thread_id' => (int) $thread->id ) : array(); 258 } 259 260 /** 201 261 * Whether a reply was sent and hasn't been counted yet. 202 262 * … … 236 296 * 237 297 * @param string $event Event name. 238 * @param callable $resolve Returns the conversation and the agent who caused the event (or null), or null to skip it. 298 * @param callable $resolve Returns the conversation, the agent who caused the event (or null), and optionally event 299 * details; or null to skip it. 239 300 * @return void 240 301 */ … … 251 312 252 313 list( $conversation, $agent ) = $args; 314 $extra = (array) ( $args[2] ?? array() ); 253 315 254 316 // Automations have no WordPress.org account to credit. … … 257 319 } 258 320 259 SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent ) );321 SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent, $extra ) ); 260 322 } catch ( \Throwable $e ) { 261 323 \Log::error( '[WPOrgWebhooks] Could not queue ' . $event . ': ' . $e->getMessage() ); -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md
r15261 r15344 1 1 # WP.org Webhooks 2 2 3 Sends conversation events to WordPress.org, which records them as contributor stats for the agents who handled them .3 Sends conversation events to WordPress.org, which records them as contributor stats for the agents who handled them, and keeps a copy of the conversations that the plugin directory reads. 4 4 5 5 ## What it sends … … 10 10 - the sender or an agent replies (an undone reply isn't counted); 11 11 - a conversation is assigned, changes status, or moves to another mailbox; 12 - conversations are merged, or one is moved to Deleted (a conversation merged into another counts as the merge). 12 - conversations are merged, or one is moved to Deleted (a conversation merged into another counts as the merge), or restored from it. 13 - an agent adds a note, which WordPress.org doesn't credit to them, but reads for the plugins and themes it mentions. 14 15 Events of conversations `WPOrgHelpScoutImport` imported count for stats, but don't carry the conversation for WordPress.org's copy until WordPress.org is pointed at their mailbox (the `wporgwebhooks.copy` filter); see its README. 13 16 14 17 Not sent: 15 18 16 - imported conversations, which the service they came from counted already (what agents do with them in FreeScout is sent);19 - imported threads as they're written, which the service they came from counted already (what agents do with them in FreeScout is sent); 17 20 - spam from senders (agents marking spam is sent). 18 21 19 22 Automations, like Workflows, have no WordPress.org account, so their events are sent without an agent. 20 23 21 Nothing from the emails themselves is sent. Events go out from FreeScout's queue, and are retried if WordPress.org can't be reached. 24 Each event also carries the conversation as WordPress.org keeps it, read when the event is sent, so a late or retried one says what's current: its mailbox, state, number, subject, status, preview, and dates, and the sender. The event's own mailbox, which stats count, stays the one it happened in. 25 26 For the plugins and themes they mention, a reply also carries the threads from the previous reply up to it (itself, and the notes before it), a note itself, and a conversation new to the copy, restored, taken out of spam, or moved to another mailbox, its newest 50 threads; other events carry none, as the copy keeps what earlier ones mentioned. When WordPress.org has no copy of a conversation yet, it takes nothing from an event without all of its threads, and asks for them: the event goes again at once with them, and only that one counts. Threads go as text, with their links' addresses, up to 256 KB an event, cutting the oldest short; an event api.wordpress.org's web server still refuses for its size goes again without them. 27 28 A merge carries the ID of the conversation merged away, and every event of an imported conversation the HelpScout ID whose copy it replaces, as does a merge for the conversation merged away (from the `wporgwebhooks.helpscout_id` filter, which `WPOrgHelpScoutImport` answers, also for conversations deleted for good since). The copy names the HelpScout conversations it replaced, which keeps HelpScout's webhook from writing them back. Whether an event carries the conversation at all is up to the `wporgwebhooks.copy` filter, which `WPOrgHelpScoutImport` answers for imported conversations. That copy is the `wporg_helpscout` table HelpScout's webhook filled, which the plugin directory lists a plugin's emails from, and finds review conversations in; deleted conversations, and spam, are taken out of it. 29 30 Events go out from FreeScout's queue, and are retried if WordPress.org can't be reached. 22 31 23 32 ## Setup -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php
r15261 r15344 34 34 35 35 /** 36 * Status the web server refuses an oversized request with, before webhook.php sees it. 37 * 38 * @var int 39 */ 40 private const PAYLOAD_TOO_LARGE = 413; 41 42 /** 36 43 * Base URL of the endpoints, with a trailing slash. 37 44 * … … 55 62 56 63 /** 64 * Guzzle handler, for tests to answer requests. 65 * 66 * @var callable|null 67 */ 68 private $handler; 69 70 /** 57 71 * Constructor. 58 72 * 59 * @param string $base_url Base URL of the endpoints. 60 * @param string $secret Shared signing secret. 61 * @param int $timeout Request timeout in seconds. 62 */ 63 public function __construct( string $base_url, string $secret, int $timeout = 10 ) { 73 * @param string $base_url Base URL of the endpoints. 74 * @param string $secret Shared signing secret. 75 * @param int $timeout Request timeout in seconds. 76 * @param callable|null $handler Guzzle handler; the default sends real requests. 77 */ 78 public function __construct( string $base_url, string $secret, int $timeout = 10, ?callable $handler = null ) { 64 79 $this->base_url = rtrim( $base_url, '/' ) . '/'; 65 80 $this->secret = $secret; 66 81 $this->timeout = $timeout; 67 } 68 69 /** 70 * Creates a client from the module configuration. 82 $this->handler = $handler; 83 } 84 85 /** 86 * Creates a client from the module configuration, or returns the one tests bound. 71 87 * 72 88 * @param int $timeout Request timeout in seconds. … … 74 90 */ 75 91 public static function from_config( int $timeout = 10 ): self { 92 if ( app()->bound( self::class ) ) { 93 return app( self::class ); 94 } 95 76 96 return new self( 77 97 (string) config( 'wporgwebhooks.api_url' ), … … 119 139 * Posts a signed payload to an endpoint. 120 140 * 121 * Only a 2xx response counts as delivered, whatever its body. Redirects aren't followed, since they'd turn the 122 * request into a GET. 141 * Only a 2xx response counts as delivered. Redirects aren't followed, since they'd turn the request into a GET. 123 142 * 124 143 * @param string $endpoint Endpoint path relative to the base URL. 125 144 * @param array $payload Request payload. 126 * @return void 127 * 128 * @throws NotDeliveredException If api.wordpress.org certainly didn't get the request: it couldn't be reached, or 129 * its web server answered for it. 130 * @throws RuntimeException If the client is not configured, or the request failed in a way that may have 131 * reached webhook.php, like timing out after it was sent. 132 */ 133 public function post( string $endpoint, array $payload ): void { 145 * @return array The response's JSON object; empty if it has none. 146 * 147 * @throws NotDeliveredException If api.wordpress.org certainly didn't get the request: it couldn't be reached, 148 * or its web server answered for it. 149 * @throws PayloadTooLargeException If its web server refused the request for its size, without passing it on. 150 * @throws RuntimeException If the client is not configured, or the request failed in a way that may have 151 * reached webhook.php, like timing out after it was sent. 152 */ 153 public function post( string $endpoint, array $payload ): array { 134 154 if ( ! $this->is_configured() ) { 135 155 throw new RuntimeException( 'WPORG_API_SECRET is not configured.' ); … … 140 160 try { 141 161 // Whatever APP_CURL_SSL_VERIFYPEER says. 142 $http = new \GuzzleHttp\Client( 143 \Helper::setGuzzleDefaultOptions( 144 array( 145 'timeout' => $this->timeout, 146 'allow_redirects' => false, 147 'verify' => true, 148 ) 149 ) 162 $options = array( 163 'timeout' => $this->timeout, 164 'allow_redirects' => false, 165 'verify' => true, 150 166 ); 167 if ( $this->handler ) { 168 $options['handler'] = \GuzzleHttp\HandlerStack::create( $this->handler ); 169 } 170 171 $http = new \GuzzleHttp\Client( \Helper::setGuzzleDefaultOptions( $options ) ); 151 172 $response = $http->post( 152 173 $this->base_url . ltrim( $endpoint, '/' ), … … 173 194 } 174 195 196 if ( self::PAYLOAD_TOO_LARGE === $status ) { 197 throw new PayloadTooLargeException( 'Request to ' . $endpoint . ' was too large: ' . strlen( $body ) . ' bytes.' ); 198 } 199 175 200 if ( $status < 200 || $status > 299 ) { 176 201 throw new RuntimeException( 'Request to ' . $endpoint . ' got a ' . $status . ' response.' ); 177 202 } 203 204 $data = json_decode( trim( (string) $response->getBody() ), true ); 205 206 return is_array( $data ) ? $data : array(); 178 207 } 179 208 } -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php
r15261 r15344 11 11 12 12 use App\Conversation; 13 use App\Thread; 13 14 use App\User; 15 use Carbon\Carbon; 14 16 use Modules\WPOrgSSO\Entities\Account; 15 17 … … 17 19 * Builds the webhook payload for a conversation event. 18 20 * 19 * Read by api.wordpress.org/dotorg/freescout/webhook.php; keep the two in sync. 21 * Read by api.wordpress.org/dotorg/freescout/webhook.php; keep the two in sync. Carries the event, for contributor 22 * stats, and the conversation, for WordPress.org's copy of it. 20 23 */ 21 24 final class EventPayload { 22 25 23 26 /** 27 * Most threads an event carries. 28 * 29 * @var int 30 */ 31 private const MAX_THREADS = 50; 32 33 /** 34 * Most bytes of thread text an event carries; past it, the oldest threads are cut short or left out. 35 * 36 * @var int 37 */ 38 private const MAX_TEXT_BYTES = 256 * 1024; 39 40 /** 41 * Events that carry all of a conversation's threads: the copy has none of it yet, or it moved to another mailbox, 42 * which changes what it mentions. See reads_all() for the rest. 43 * 44 * @var string[] 45 */ 46 private const ALL_THREADS_EVENTS = array( 47 'conversation.created_by_customer', 48 'conversation.created_by_user', 49 'conversation.restored', 50 'conversation.moved', 51 ); 52 53 /** 54 * Events that carry the threads since the previous reply: the reply, and the notes before it. Events not listed carry 55 * none. 56 * 57 * @var string[] 58 */ 59 private const NEW_THREADS_EVENTS = array( 60 'conversation.customer_replied', 61 'conversation.user_replied', 62 ); 63 64 /** 65 * Events that carry only the thread they're about. 66 * 67 * @var string[] 68 */ 69 private const OWN_THREAD_EVENTS = array( 'conversation.note_added' ); 70 71 /** 72 * Statuses, as WordPress.org's copy names them. 73 * 74 * @var string[] 75 */ 76 public const STATUSES = array( 77 Conversation::STATUS_ACTIVE => 'active', 78 Conversation::STATUS_PENDING => 'pending', 79 Conversation::STATUS_CLOSED => 'closed', 80 Conversation::STATUS_SPAM => 'spam', 81 ); 82 83 /** 84 * Thread types, by name. 85 * 86 * @var string[] 87 */ 88 private const THREAD_TYPES = array( 89 Thread::TYPE_CUSTOMER => 'customer', 90 Thread::TYPE_MESSAGE => 'message', 91 Thread::TYPE_NOTE => 'note', 92 ); 93 94 /** 24 95 * Builds the payload. 96 * 97 * The conversation is added when the event is sent; see refresh(). 25 98 * 26 99 * @param string $event Event name: the FreeScout hook that fired. 27 100 * @param Conversation $conversation Conversation the event happened on. 28 101 * @param User|null $agent Agent who caused the event, if any. 102 * @param array $extra Event details, like the ID of a conversation merged into this one. 29 103 * @return array 30 104 */ 31 public static function build( string $event, Conversation $conversation, ?User $agent ): array { 32 $mailbox = $conversation->mailbox; 33 34 return array( 105 public static function build( string $event, Conversation $conversation, ?User $agent, array $extra = array() ): array { 106 return $extra + array( 35 107 'event' => $event, 36 108 'conversation' => array( 37 109 'id' => (int) $conversation->id, 38 110 ), 39 'mailbox' => array( 40 'id' => (int) $conversation->mailbox_id, 41 'name' => $mailbox ? (string) $mailbox->name : '', 42 ), 111 'mailbox' => self::mailbox( $conversation ), 43 112 'agent' => $agent ? array( 44 113 'id' => (int) $agent->id, … … 50 119 51 120 /** 121 * Adds the conversation as it is now: events can be sent late or out of order, and the copy takes what the latest 122 * says. The event's own mailbox stays, for stats. 123 * 124 * @param array $payload The payload; `all_threads` if WordPress.org asked for them. 125 * @return array 126 */ 127 public static function refresh( array $payload ): array { 128 $id = (int) ( $payload['conversation']['id'] ?? 0 ); 129 $conversation = Conversation::find( $id ); 130 $mailbox_id = $conversation ? (int) $conversation->mailbox_id : (int) ( $payload['mailbox']['id'] ?? 0 ); 131 132 /** 133 * Filters whether an event carries the conversation for WordPress.org's copy; it counts for stats either way. 134 * 135 * @param bool $copy Whether it does. 136 * @param Conversation|null $conversation Conversation; null if it was deleted for good since the event. 137 * @param int $conversation_id Its ID. 138 * @param int $mailbox_id Its mailbox's ID; the event's, if it was deleted for good. 139 */ 140 if ( ! \Eventy::filter( 'wporgwebhooks.copy', true, $conversation, $id, $mailbox_id ) ) { 141 unset( $payload['email'] ); 142 143 return $payload; 144 } 145 146 $helpscout_id = self::helpscout_id( $conversation, $id ); 147 if ( $helpscout_id && empty( $payload['helpscout_id'] ) ) { 148 $payload['helpscout_id'] = $helpscout_id; 149 } 150 151 // The conversation merged into this one may have a HelpScout copy too. 152 $merged_id = (int) ( $payload['merged_id'] ?? 0 ); 153 if ( $merged_id && empty( $payload['merged_helpscout_id'] ) ) { 154 $merged_helpscout_id = self::helpscout_id( Conversation::find( $merged_id ), $merged_id ); 155 if ( $merged_helpscout_id ) { 156 $payload['merged_helpscout_id'] = $merged_helpscout_id; 157 } 158 } 159 160 $payload['email'] = $conversation ? self::email( $conversation, $payload ) : array( 'state' => 'deleted' ); 161 162 return $payload; 163 } 164 165 /** 166 * The ID of the HelpScout conversation a conversation was imported from, whose copy its own replaces. 167 * 168 * @param Conversation|null $conversation Conversation; null if it was deleted for good. 169 * @param int $conversation_id Its ID. 170 * @return int 0 if it wasn't imported. 171 */ 172 private static function helpscout_id( ?Conversation $conversation, int $conversation_id ): int { 173 /** 174 * Filters the ID of the HelpScout conversation a conversation was imported from. 175 * 176 * @param int $helpscout_id HelpScout's ID; 0 if it wasn't imported. 177 * @param Conversation|null $conversation Conversation; null if it was deleted for good. 178 * @param int $conversation_id Its ID. 179 */ 180 return (int) \Eventy::filter( 'wporgwebhooks.helpscout_id', 0, $conversation, $conversation_id ); 181 } 182 183 /** 184 * The conversation's mailbox. 185 * 186 * @param Conversation $conversation Conversation. 187 * @return array 188 */ 189 private static function mailbox( Conversation $conversation ): array { 190 $mailbox = $conversation->mailbox; 191 192 return array( 193 'id' => (int) $conversation->mailbox_id, 194 'name' => $mailbox ? (string) $mailbox->name : '', 195 ); 196 } 197 198 /** 199 * The conversation, as WordPress.org keeps a copy of it. 200 * 201 * @param Conversation $conversation Conversation. 202 * @param array $payload The event's payload. 203 * @return array 204 */ 205 private static function email( Conversation $conversation, array $payload ): array { 206 $customer = $conversation->customer; 207 $all = self::reads_all( $payload ); 208 209 return array( 210 'mailbox' => self::mailbox( $conversation ), 211 'state' => Conversation::STATE_DELETED === (int) $conversation->state ? 'deleted' : 'published', 212 'number' => (int) $conversation->number, 213 'subject' => (string) $conversation->subject, 214 'status' => self::STATUSES[ (int) $conversation->status ] ?? 'active', 215 'preview' => (string) $conversation->preview, 216 'created_at' => self::date( $conversation->created_at ), 217 'closed_at' => self::date( $conversation->closed_at ), 218 'user_updated_at' => self::date( $conversation->user_updated_at ), 219 'sender' => array( 220 'email' => (string) $conversation->customer_email, 221 'emails' => $customer ? $customer->emails->pluck( 'email' )->values()->all() : array(), 222 'first_name' => $customer ? (string) $customer->first_name : '', 223 'last_name' => $customer ? (string) $customer->last_name : '', 224 ), 225 'all_threads' => $all, 226 'threads' => self::threads( $conversation, $payload, $all ), 227 ); 228 } 229 230 /** 231 * Whether WordPress.org reads all of a conversation's threads after an event. 232 * 233 * @param array $payload The event's payload. 234 * @return bool 235 */ 236 private static function reads_all( array $payload ): bool { 237 return ! empty( $payload['all_threads'] ) 238 || ! empty( $payload['unspammed'] ) 239 || in_array( (string) ( $payload['event'] ?? '' ), self::ALL_THREADS_EVENTS, true ); 240 } 241 242 /** 243 * The threads WordPress.org reads after an event, newest first, as text. 244 * 245 * @param Conversation $conversation Conversation. 246 * @param array $payload The event's payload: its name, and the reply it's about. 247 * @param bool $all Whether WordPress.org reads all of them; see reads_all(). 248 * @return array[] Type, and body, as text; see text(). 249 */ 250 private static function threads( Conversation $conversation, array $payload, bool $all ): array { 251 $event = (string) ( $payload['event'] ?? '' ); 252 $thread_id = (int) ( $payload['thread_id'] ?? 0 ); 253 $own = ! $all && in_array( $event, self::OWN_THREAD_EVENTS, true ); 254 if ( ! $all && ! $own && ! in_array( $event, self::NEW_THREADS_EVENTS, true ) ) { 255 return array(); 256 } 257 258 $query = $conversation->threads() 259 ->where( 'state', Thread::STATE_PUBLISHED ) 260 ->whereIn( 'type', array_keys( self::THREAD_TYPES ) ); 261 262 // A reply's threads end at it, even if more came before the event was sent. 263 if ( $own ) { 264 $query->where( 'id', $thread_id ); 265 } elseif ( ! $all && $thread_id ) { 266 $query->where( 'id', '<=', $thread_id )->orderBy( 'id', 'desc' ); 267 } else { 268 $query->orderBy( 'created_at', 'desc' )->orderBy( 'id', 'desc' ); 269 } 270 271 $sent = array(); 272 $replies = 0; 273 $budget = self::MAX_TEXT_BYTES; 274 foreach ( $query->limit( self::MAX_THREADS )->get( array( 'type', 'body' ) ) as $thread ) { 275 $is_reply = Thread::TYPE_NOTE !== (int) $thread->type; 276 if ( ( ! $all && $is_reply && ++$replies > 1 ) || $budget <= 0 ) { 277 break; 278 } 279 280 $text = mb_strcut( self::text( (string) $thread->body ), 0, $budget, 'UTF-8' ); 281 $budget -= strlen( $text ); 282 $sent[] = array( 283 'type' => self::THREAD_TYPES[ (int) $thread->type ], 284 'body' => $text, 285 ); 286 } 287 288 return $sent; 289 } 290 291 /** 292 * A thread's HTML as text, for finding the plugins and themes it mentions: a line per block, and link addresses on 293 * lines of their own. 294 * 295 * Angle brackets are removed, so stripping tags on WordPress.org can't swallow text. 296 * 297 * @param string $html The thread's body. 298 * @return string 299 */ 300 public static function text( string $html ): string { 301 $html = mb_scrub( $html, 'UTF-8' ); 302 $html = (string) preg_replace( '#<(script|style)\b[^>]*>.*?</\1\s*>#is', '', $html ); 303 304 preg_match_all( '#\bhref\s*=\s*(["\'])(.*?)\1#is', $html, $links ); 305 306 $text = (string) preg_replace( '#<br\b[^>]*>|</(?:p|div|li|tr|h[1-6]|blockquote|pre)\s*>#i', "\n", $html ); 307 $text = self::decode( strip_tags( $text ) ); 308 309 $urls = array(); 310 foreach ( $links[2] as $url ) { 311 $url = trim( self::decode( $url ) ); 312 if ( '' !== $url && ! str_contains( $text, $url ) ) { 313 $urls[ $url ] = true; 314 } 315 } 316 317 $text = (string) preg_replace( '/[ \t\x{00A0}]+/u', ' ', $text . "\n" . implode( "\n", array_keys( $urls ) ) ); 318 319 return trim( (string) preg_replace( '/ *\n\s*/', "\n", $text ) ); 320 } 321 322 /** 323 * Decodes HTML entities, and takes out angle brackets; see text(). 324 * 325 * @param string $text Text with entities. 326 * @return string 327 */ 328 private static function decode( string $text ): string { 329 return str_replace( array( '<', '>' ), ' ', html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); 330 } 331 332 /** 333 * A stored time, in UTC, ISO 8601. 334 * 335 * @param mixed $date Time in the app's timezone, as a Carbon instance or a string; empty for none. 336 * @return string|null 337 */ 338 private static function date( $date ): ?string { 339 if ( ! $date ) { 340 return null; 341 } 342 343 return Carbon::parse( (string) $date, config( 'app.timezone' ) )->setTimezone( 'UTC' )->format( 'Y-m-d\TH:i:s\Z' ); 344 } 345 346 /** 52 347 * The WordPress.org account WPOrgSSO connected an agent to, which contributor stats credit. 53 348 * -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/module.json
r15244 r15344 4 4 "img": "/modules/wporgwebhooks/img/icon.svg", 5 5 "description": "Sends conversation events to WordPress.org, which records them as contributor stats.", 6 "version": "1. 0.0",6 "version": "1.1.0", 7 7 "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks", 8 8 "author": "WordPress.org", -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/EventForwardingTest.php
r15261 r15344 17 17 use Modules\WPOrgWebhooks\Jobs\SendEvent; 18 18 use Modules\WPOrgWebhooks\Providers\WPOrgWebhooksServiceProvider; 19 use Modules\WPOrgWebhooks\Services\EventPayload; 19 20 use WordPressdotorg\FreeScout\Tests\TestCase; 20 21 … … 171 172 $this->assert_queued( 'conversation.merged', (string) $this->agent->email ); 172 173 Queue::assertPushed( SendEvent::class, 1 ); 174 175 $this->assertSame( (int) $second->id, $this->payload()['merged_id'] ); 176 } 177 178 /** 179 * Restoring a conversation from Deleted is sent, so WordPress.org copies it again; other state changes aren't. 180 * 181 * @return void 182 */ 183 public function test_restoring_a_conversation_is_forwarded(): void { 184 \Eventy::action( 'conversation.state_changed', $this->conversation, $this->agent, Conversation::STATE_DRAFT ); 185 Queue::assertNotPushed( SendEvent::class ); 186 187 \Eventy::action( 'conversation.state_changed', $this->conversation, $this->agent, Conversation::STATE_DELETED ); 188 $this->assert_queued( 'conversation.restored', (string) $this->agent->email ); 189 } 190 191 /** 192 * Events carry the conversation as WordPress.org keeps a copy of it: its details, and sender; one that doesn't add a 193 * thread, no threads. 194 * 195 * @return void 196 */ 197 public function test_events_carry_the_conversation(): void { 198 $this->conversation->update( 199 array( 200 'subject' => '[WordPress Plugin Directory] Review in Progress: My Plugin', 201 'status' => Conversation::STATUS_CLOSED, 202 ) 203 ); 204 $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 205 $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); 206 207 \Eventy::action( 'conversation.status_changed', $this->conversation->fresh(), $this->agent, false, Conversation::STATUS_ACTIVE ); 208 209 $email = $this->payload()['email']; 210 $this->assertSame( (int) $this->conversation->number, $email['number'] ); 211 $this->assertSame( '[WordPress Plugin Directory] Review in Progress: My Plugin', $email['subject'] ); 212 $this->assertSame( 'closed', $email['status'] ); 213 $this->assertMatchesRegularExpression( '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', (string) $email['created_at'] ); 214 $this->assertSame( $this->conversation->customer_email, $email['sender']['email'] ); 215 $this->assertSame( array( 'Jane', 'Sender' ), array( $email['sender']['first_name'], $email['sender']['last_name'] ) ); 216 $this->assertSame( array(), $email['threads'] ); 217 } 218 219 /** 220 * A reply carries the threads since the previous reply, newest first: itself, and the notes before it. 221 * 222 * @return void 223 */ 224 public function test_replies_carry_the_threads_since_the_previous_reply(): void { 225 $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 226 $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); 227 $reply = $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Thanks', $this->agent, '2026-01-03 10:00:00' ); 228 229 \Eventy::action( 'conversation.user_replied', $this->conversation, $reply ); 230 231 $this->assertSame( 232 array( 233 array( 234 'type' => 'message', 235 'body' => 'Thanks', 236 ), 237 array( 238 'type' => 'note', 239 'body' => 'https://wordpress.org/plugins/my-plugin/', 240 ), 241 ), 242 $this->payload()['email']['threads'] 243 ); 244 } 245 246 /** 247 * A note goes out with itself, for the plugins and themes it mentions. 248 * 249 * @return void 250 */ 251 public function test_notes_carry_themselves(): void { 252 $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 253 $note = $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); 254 255 \Eventy::action( 'conversation.note_added', $this->conversation, $note ); 256 257 $this->assert_queued( 'conversation.note_added', (string) $this->agent->email ); 258 $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/' ), array_column( $this->payload()['email']['threads'], 'body' ) ); 259 } 260 261 /** 262 * A reply's threads end with it, even when replies came after it before the event was sent. 263 * 264 * @return void 265 */ 266 public function test_late_replies_carry_their_own_threads(): void { 267 $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Hello', $this->agent, '2026-01-01 10:00:00' ); 268 $reply = $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'https://wordpress.org/plugins/my-plugin/', null, '2026-01-02 10:00:00' ); 269 270 \Eventy::action( 'conversation.customer_replied', $this->conversation, $reply ); 271 $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Thanks', $this->agent, '2026-01-03 10:00:00' ); 272 273 $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/' ), array_column( $this->payload()['email']['threads'], 'body' ) ); 274 } 275 276 /** 277 * Events the filter keeps out of the copy still go out for stats, without the conversation. 278 * 279 * @return void 280 */ 281 public function test_events_kept_out_of_the_copy_still_count(): void { 282 $filter = static function (): bool { 283 return false; 284 }; 285 \Eventy::addFilter( 'wporgwebhooks.copy', $filter ); 286 287 \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); 288 $payload = $this->payload(); 289 \Eventy::removeFilter( 'wporgwebhooks.copy', $filter ); 290 291 $this->assertSame( (string) $this->agent->email, $payload['agent']['email'] ); 292 $this->assertArrayNotHasKey( 'email', $payload ); 293 } 294 295 /** 296 * Every event of an imported conversation names the HelpScout conversation whose copy it replaces. 297 * 298 * @return void 299 */ 300 public function test_events_name_the_helpscout_conversation_they_replace(): void { 301 $filter = static function (): int { 302 return 1000000028; 303 }; 304 \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter ); 305 306 \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); 307 $payload = $this->payload(); 308 \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); 309 310 $this->assertSame( 1000000028, $payload['helpscout_id'] ); 311 } 312 313 /** 314 * A late event counts for the mailbox it happened in; the copy takes the one the conversation is in now. 315 * 316 * @return void 317 */ 318 public function test_late_events_keep_their_mailbox_for_stats(): void { 319 \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); 320 $this->conversation->mailbox_id = $this->create_mailbox( 'Themes' )->id; 321 $this->conversation->save(); 322 323 $payload = $this->payload(); 324 325 $this->assertSame( 'Plugins', $payload['mailbox']['name'] ); 326 $this->assertSame( 'Themes', $payload['email']['mailbox']['name'] ); 327 } 328 329 /** 330 * A conversation deleted for good before its event went out is deleted from the copy, with its HelpScout one, which 331 * the filter is asked about by ID. 332 * 333 * @return void 334 */ 335 public function test_events_of_conversations_deleted_since_name_their_helpscout_conversation(): void { 336 $id = (int) $this->conversation->id; 337 $filter = static function ( $helpscout_id = 0, $conversation = null, $conversation_id = 0 ) use ( $id ): int { 338 return null === $conversation && $id === (int) $conversation_id ? 1000000028 : (int) $helpscout_id; 339 }; 340 \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter, 10, 3 ); 341 342 \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); 343 Conversation::deleteConversationsForever( array( $id ) ); 344 $payload = $this->payload(); 345 \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); 346 347 $this->assertSame( array( 'state' => 'deleted' ), $payload['email'] ); 348 $this->assertSame( 1000000028, $payload['helpscout_id'] ); 349 } 350 351 /** 352 * A merge names the HelpScout conversation the conversation merged away was imported from, whose copy goes too. 353 * 354 * @return void 355 */ 356 public function test_merges_name_the_helpscout_conversation_of_the_one_merged_away(): void { 357 $second = $this->create_conversation( $this->conversation->mailbox, $this->create_sender() ); 358 $filter = static function ( $helpscout_id = 0, $conversation = null, $conversation_id = 0 ) use ( $second ): int { 359 return (int) $second->id === (int) $conversation_id ? 1000000029 : (int) $helpscout_id; 360 }; 361 \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter, 10, 3 ); 362 363 \Eventy::action( 'conversation.merged', $this->conversation, $second, $this->agent ); 364 $payload = $this->payload(); 365 \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); 366 367 $this->assertSame( array( (int) $second->id, 1000000029 ), array( $payload['merged_id'], $payload['merged_helpscout_id'] ) ); 368 $this->assertArrayNotHasKey( 'helpscout_id', $payload ); 369 } 370 371 /** 372 * Asked for all of the threads, an event carries them, and says so, whatever it is. 373 * 374 * @return void 375 */ 376 public function test_events_carry_all_threads_when_asked(): void { 377 $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 378 $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); 379 380 \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); 381 $job = Queue::pushed( SendEvent::class )->first(); 382 383 $this->assertFalse( EventPayload::refresh( $job->payload )['email']['all_threads'] ); 384 385 $email = EventPayload::refresh( array( 'all_threads' => true ) + $job->payload )['email']; 386 $this->assertTrue( $email['all_threads'] ); 387 $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/', 'Please review' ), array_column( $email['threads'], 'body' ) ); 388 } 389 390 /** 391 * Threads go as text: a line per block, and their links' addresses, where WordPress.org looks for plugins and themes. 392 * 393 * @return void 394 */ 395 public function test_threads_go_as_text(): void { 396 $this->assertSame( 397 "Hi Jane,\nSee your plugin and\nPlugin: my-plugin\nUse wp_enqueue script &\nhttps://wordpress.org/plugins/my-plugin/", 398 EventPayload::text( '<style>p { color: red; }</style><p>Hi Jane,</p><p>See <a href="https://wordpress.org/plugins/my-plugin/">your plugin</a> and<br>Plugin: my-plugin</p><div>Use wp_enqueue <script> &</div>' ) 399 ); 400 } 401 402 /** 403 * A conversation's threads, sent as it moves, stop at a size WordPress.org takes, cutting the oldest short. 404 * 405 * @return void 406 */ 407 public function test_threads_stop_at_a_size(): void { 408 $long = str_repeat( 'x', 100 * 1024 ); 409 foreach ( array( 1, 2, 3, 4 ) as $day ) { 410 $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, $long, null, '2026-01-0' . $day . ' 10:00:00' ); 411 } 412 413 \Eventy::action( 'conversation.moved', $this->conversation, $this->agent ); 414 $threads = $this->payload()['email']['threads']; 415 416 $this->assertCount( 3, $threads ); 417 $this->assertSame( array( 100 * 1024, 100 * 1024, 56 * 1024 ), array_map( 'strlen', array_column( $threads, 'body' ) ) ); 173 418 } 174 419 … … 255 500 256 501 Queue::assertNotPushed( SendEvent::class ); 502 } 503 504 /** 505 * The payload of the one event queued. 506 * 507 * @return array 508 */ 509 private function payload(): array { 510 $jobs = Queue::pushed( SendEvent::class ); 511 $this->assertCount( 1, $jobs ); 512 513 // As SendEvent sends it. 514 return EventPayload::refresh( $jobs->first()->payload ); 257 515 } 258 516 -
sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php
r15244 r15344 10 10 namespace Modules\WPOrgWebhooks\Tests; 11 11 12 use App\Thread; 13 use GuzzleHttp\Handler\MockHandler; 14 use GuzzleHttp\Promise\PromiseInterface; 15 use GuzzleHttp\Psr7\Response; 12 16 use Illuminate\Queue\Jobs\DatabaseJob; 13 17 use Modules\WPOrgWebhooks\Jobs\SendEvent; 14 18 use Modules\WPOrgWebhooks\Providers\WPOrgWebhooksServiceProvider; 19 use Modules\WPOrgWebhooks\Services\Client; 20 use Modules\WPOrgWebhooks\Services\EventPayload; 21 use Psr\Http\Message\RequestInterface; 15 22 use WordPressdotorg\FreeScout\Tests\TestCase; 16 23 … … 72 79 73 80 /** 81 * WordPress.org asks for all of the threads of a conversation it has no copy of, and gets the event again with them; 82 * only that one counts. 83 * 84 * @return void 85 */ 86 public function test_sends_all_threads_when_asked(): void { 87 $conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); 88 $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 89 $this->create_thread( $conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->create_user(), '2026-01-02 10:00:00' ); 90 $sent = $this->answer( new Response( 200, array(), '{"threads":"all"}' ), new Response( 200, array(), '{}' ) ); 91 92 $queue_job = $this->queue_job( 1 ); 93 $queue_job->expects( $this->never() )->method( 'release' ); 94 $this->send_event( $queue_job, EventPayload::build( 'conversation.status_changed', $conversation, null ) ); 95 96 $this->assertCount( 2, $sent ); 97 $this->assertSame( array( false, array() ), array( $sent[0]['email']['all_threads'], $sent[0]['email']['threads'] ) ); 98 $this->assertTrue( $sent[1]['email']['all_threads'] ); 99 $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/', 'Please review' ), array_column( $sent[1]['email']['threads'], 'body' ) ); 100 } 101 102 /** 103 * An event too large for api.wordpress.org's web server goes again without its threads. 104 * 105 * @return void 106 */ 107 public function test_too_large_events_go_without_their_threads(): void { 108 $conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); 109 $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); 110 $sent = $this->answer( new Response( 413 ), new Response( 200, array(), '{}' ) ); 111 112 $queue_job = $this->queue_job( 1 ); 113 $queue_job->expects( $this->never() )->method( 'markAsFailed' ); 114 $this->send_event( $queue_job, EventPayload::build( 'conversation.moved', $conversation, null ) ); 115 116 $this->assertCount( 2, $sent ); 117 $this->assertCount( 1, $sent[0]['email']['threads'] ); 118 $this->assertSame( array(), $sent[1]['email']['threads'] ); 119 $this->assertSame( 'conversation.moved', $sent[1]['event'] ); 120 } 121 122 /** 123 * Answers api.wordpress.org's requests in turn. 124 * 125 * @param Response ...$responses What it answers. 126 * @return \ArrayObject The payloads it was sent, as they arrive. 127 */ 128 private function answer( Response ...$responses ): \ArrayObject { 129 $sent = new \ArrayObject(); 130 $handler = new MockHandler( $responses ); 131 $this->app->instance( 132 Client::class, 133 new Client( 134 'https://api.wordpress.test/', 135 'test-secret', 136 5, 137 static function ( RequestInterface $request, array $options ) use ( $sent, $handler ): PromiseInterface { 138 $sent[] = json_decode( (string) $request->getBody(), true ); 139 140 return $handler( $request, $options ); 141 } 142 ) 143 ); 144 145 return $sent; 146 } 147 148 /** 74 149 * Mocks the queued job an event runs as; FreeScout queues in the database. 75 150 * … … 88 163 * 89 164 * @param DatabaseJob $queue_job Queued job the event runs as. 165 * @param array $payload Event payload. 90 166 * @return void 91 167 */ 92 private function send_event( DatabaseJob $queue_job ): void {93 $event = new SendEvent( array( 'event' => 'conversation.user_replied' ));168 private function send_event( DatabaseJob $queue_job, array $payload = array( 'event' => 'conversation.user_replied' ) ): void { 169 $event = new SendEvent( $payload ); 94 170 $event->setJob( $queue_job ); 95 171 $event->handle();
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)