Making WordPress.org

Changeset 15344


Ignore:
Timestamp:
10/15/2026 02:49:54 PM (8 days ago)
Author:
obenland
Message:

FreeScout: Keep WordPress.org's copy of conversations up to date

Closes ​https://github.com/WordPress/wordpress.org/pull/1051.

Location:
sites/trunk
Files:
8 added
23 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php

    r15261 r15344  
    5050function wp_strip_all_tags( string $text ): string {
    5151        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 */
     60function sanitize_title( string $title ): string {
     61        return trim( (string) preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' );
    5262}
    5363
    … …  
    105115
    106116require_once dirname( __DIR__ ) . '/common.php';
     117require_once dirname( __DIR__ ) . '/copies.php';
  • sites/trunk/api.wordpress.org/public_html/dotorg/freescout/webhook.php

    r15261 r15344  
    11<?php
    22/**
    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.
    44 *
    55 * @package WordPressdotorg\API\FreeScout
    … …  
    1111
    1212require __DIR__ . '/common.php';
     13require __DIR__ . '/copies.php';
    1314
    1415/**
    … …  
    1819 */
    1920const 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 */
     28const 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 */
     35const AUTOMATION_USER_IDS = array( 5911429 ); // wordpressdotorg.
    2036
    2137/**
    … …  
    3046        bump_stats_extra( 'freescout', $event );
    3147
    32         if ( empty( $request->agent->id ) ) {
     48        if ( empty( $request->agent->id ) || in_array( $event, UNCREDITED_EVENTS, true ) ) {
    3349                return;
    3450        }
    … …  
    3652        $username   = (string) ( $request->agent->wporg_username ?? '' );
    3753        $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' );
    4161
    4262        if ( in_array( $event, REPLY_EVENTS, true ) ) {
    … …  
    5373}
    5474
    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.
     78try {
     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}
    5685
    5786header( '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.
     89if ( $needs_all_threads ) {
     90        echo '{"threads":"all"}';
     91        exit;
     92}
     93
     94contributor_stats( $request );
     95
    5896echo '{}';
  • sites/trunk/api.wordpress.org/public_html/dotorg/helpscout/common.php

    r14781 r15344  
    545545
    546546/**
     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 */
     554function 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/**
    547568 * Keep a cached copy of the received emails in the database for querying.
    548569 *
    … …  
    558579
    559580        if ( empty( $request->id ) ) {
     581                return;
     582        }
     583
     584        if ( is_moved_to_freescout( $request->id ) ) {
    560585                return;
    561586        }
  • sites/trunk/environments/freescout/mock-api/index.php

    r15289 r15344  
    8989}
    9090
     91if ( '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
    9197/*
    9298 * 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  
    22-- These tables exist outside WordPress on production but are needed locally.
    33
     4-- WordPress.org's copy of helpdesk conversations, which api.wordpress.org's webhooks fill.
     5CREATE 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.
    421CREATE 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;
    1130
    1231CREATE TABLE IF NOT EXISTS `wp_svn_access` (
  • sites/trunk/freescout.wordpress.net/AGENTS.md

    r15289 r15344  
    1111- `Modules/<Name>/` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`).
    1212  - `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.
    1414  - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`.
    1515  - `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.
    1616  - `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.
    1818- `tests/` — shared PHPUnit bootstrap and base `TestCase`.
    1919
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php

    r15265 r15344  
    1919use Modules\WPOrgHelpScoutImport\Entities\Run;
    2020use Modules\WPOrgHelpScoutImport\Jobs\ImportPage;
     21use Modules\WPOrgHelpScoutImport\Services\Copies;
    2122use Modules\WPOrgHelpScoutImport\Services\HelpScout;
    2223use Modules\WPOrgHelpScoutImport\Services\People;
    … …  
    8384                                // Imports bring saved replies only while there's somewhere to put them.
    8485                                'replies'    => SavedReplies::available(),
     86                                'copies'     => self::copies(),
    8587                        )
    8688                );
     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;
    87132        }
    88133
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php

    r15265 r15344  
    2323                Route::get( '/helpscout-import/users/export', 'AgentsController@export' )->name( 'wporghelpscoutimport.agents.export' );
    2424                Route::post( '/helpscout-import/users/connect', 'AgentsController@connect' )->name( 'wporghelpscoutimport.agents.connect' );
     25                Route::post( '/helpscout-import/copies', 'ImportController@point' )->name( 'wporghelpscoutimport.copies' );
    2526                Route::post( '/helpscout-import/{id}/pause', 'ImportController@pause' )->name( 'wporghelpscoutimport.pause' );
    2627                Route::post( '/helpscout-import/{id}/resume', 'ImportController@resume' )->name( 'wporghelpscoutimport.resume' );
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php

    r15285 r15344  
    1818use Modules\WPOrgHelpScoutImport\Exceptions\ApiError;
    1919use Modules\WPOrgHelpScoutImport\Exceptions\RateLimited;
     20use Modules\WPOrgHelpScoutImport\Services\Copies;
    2021use Modules\WPOrgHelpScoutImport\Services\HelpScout;
    2122use Modules\WPOrgHelpScoutImport\Services\Importer;
    … …  
    261262                        $run->status      = Run::STATUS_DONE;
    262263                        $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                        }
    264268
    265269                        return;
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php

    r15265 r15344  
    1010namespace Modules\WPOrgHelpScoutImport\Providers;
    1111
     12use App\Conversation;
    1213use Illuminate\Support\ServiceProvider;
     14use Modules\WPOrgHelpScoutImport\Entities\ImportedConversation;
     15use Modules\WPOrgHelpScoutImport\Services\Copies;
    1316use Modules\WPOrgHelpScoutImport\Services\HelpScout;
    1417
    … …  
    120123                );
    121124
     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
    122165                // Highlights Manage while the pages are open.
    123166                \Eventy::addFilter(
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md

    r15287 r15344  
    4040  - 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.
    4141- **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` leave them 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.
    4343  - 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.
    4444  - The same goes for conversations still being worked on in HelpScout, while both run.
    … …  
    5757- 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.
    5858- 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.
    5968- Saved replies, tags, and custom fields: switch on the Saved Replies, Tags, and Custom Fields modules before importing.
    6069- 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  
    214214                        </table>
    215215                @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
    216264        </div>
    217265</div>
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json

    r15265 r15344  
    44    "img": "/modules/wporghelpscoutimport/img/icon.svg",
    55    "description": "Imports HelpScout mailboxes' conversations, so their history moves to FreeScout with them.",
    6     "version": "1.0.0",
     6    "version": "1.1.0",
    77    "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport",
    88    "author": "WordPress.org",
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php

    r15265 r15344  
    1717use Modules\WPOrgHelpScoutImport\Entities\Run;
    1818use Modules\WPOrgHelpScoutImport\Jobs\ImportPage;
     19use Modules\WPOrgHelpScoutImport\Services\Copies;
    1920
    2021require_once __DIR__ . '/ImportTestCase.php';
    … …  
    303304
    304305        /**
     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        /**
    305333         * A run that hasn't saved progress for a while is shown stalled, and can be resumed.
    306334         *
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php

    r15287 r15344  
    2222use Modules\WPOrgHelpScoutImport\Entities\ImportedThread;
    2323use Modules\WPOrgHelpScoutImport\Entities\Person;
     24use Modules\WPOrgHelpScoutImport\Providers\WPOrgHelpScoutImportServiceProvider;
    2425use Modules\WPOrgHelpScoutImport\Services\HelpScout;
    2526use Modules\WPOrgHelpScoutImport\Services\Importer;
    … …  
    393394                $this->assertSame( (int) $themes->id, (int) $this->imported_conversation()->mailbox_id );
    394395                $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() ) );
    395409        }
    396410
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php

    r15244 r15344  
    1515use Illuminate\Queue\InteractsWithQueue;
    1616use Modules\WPOrgWebhooks\Services\Client;
     17use Modules\WPOrgWebhooks\Services\EventPayload;
    1718use Modules\WPOrgWebhooks\Services\NotDeliveredException;
     19use Modules\WPOrgWebhooks\Services\PayloadTooLargeException;
    1820
    1921/**
    … …  
    6062         * Sends the event, and tries again later if api.wordpress.org didn't get it.
    6163         *
    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.
    6467         *
    6568         * @return void
    … …  
    6770        public function handle(): void {
    6871                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                        }
    7078                } catch ( NotDeliveredException $e ) {
    7179                        if ( $this->attempts() < $this->tries ) {
    … …  
    8088                }
    8189        }
     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        }
    82132}
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php

    r15259 r15344  
    9292                                                $event,
    9393                                                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 ) );
    9595                                                }
    9696                                        );
    … …  
    107107                                        'conversation.created_by_user',
    108108                                        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;
    110110                                        }
    111111                                );
    … …  
    121121                                        'conversation.user_replied',
    122122                                        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;
    124124                                        }
    125125                                );
    … …  
    129129                );
    130130
    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 ) {
    132164                        \Eventy::addAction(
    133165                                $event,
    … …  
    150182                                self::forward(
    151183                                        '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;
    154204                                        }
    155205                                );
    … …  
    199249
    200250        /**
     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        /**
    201261         * Whether a reply was sent and hasn't been counted yet.
    202262         *
    … …  
    236296         *
    237297         * @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.
    239300         * @return void
    240301         */
    … …  
    251312
    252313                        list( $conversation, $agent ) = $args;
     314                        $extra                        = (array) ( $args[2] ?? array() );
    253315
    254316                        // Automations have no WordPress.org account to credit.
    … …  
    257319                        }
    258320
    259                         SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent ) );
     321                        SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent, $extra ) );
    260322                } catch ( \Throwable $e ) {
    261323                        \Log::error( '[WPOrgWebhooks] Could not queue ' . $event . ': ' . $e->getMessage() );
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md

    r15261 r15344  
    11# WP.org Webhooks
    22
    3 Sends conversation events to WordPress.org, which records them as contributor stats for the agents who handled them.
     3Sends 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.
    44
    55## What it sends
    … …  
    1010- the sender or an agent replies (an undone reply isn't counted);
    1111- 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
     15Events 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.
    1316
    1417Not sent:
    1518
    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);
    1720- spam from senders (agents marking spam is sent).
    1821
    1922Automations, like Workflows, have no WordPress.org account, so their events are sent without an agent.
    2023
    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.
     24Each 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
     26For 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
     28A 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
     30Events go out from FreeScout's queue, and are retried if WordPress.org can't be reached.
    2231
    2332## Setup
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php

    r15261 r15344  
    3434
    3535        /**
     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        /**
    3643         * Base URL of the endpoints, with a trailing slash.
    3744         *
    … …  
    5562
    5663        /**
     64         * Guzzle handler, for tests to answer requests.
     65         *
     66         * @var callable|null
     67         */
     68        private $handler;
     69
     70        /**
    5771         * Constructor.
    5872         *
    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 ) {
    6479                $this->base_url = rtrim( $base_url, '/' ) . '/';
    6580                $this->secret   = $secret;
    6681                $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.
    7187         *
    7288         * @param int $timeout Request timeout in seconds.
    … …  
    7490         */
    7591        public static function from_config( int $timeout = 10 ): self {
     92                if ( app()->bound( self::class ) ) {
     93                        return app( self::class );
     94                }
     95
    7696                return new self(
    7797                        (string) config( 'wporgwebhooks.api_url' ),
    … …  
    119139         * Posts a signed payload to an endpoint.
    120140         *
    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.
    123142         *
    124143         * @param string $endpoint Endpoint path relative to the base URL.
    125144         * @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 {
    134154                if ( ! $this->is_configured() ) {
    135155                        throw new RuntimeException( 'WPORG_API_SECRET is not configured.' );
    … …  
    140160                try {
    141161                        // 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,
    150166                        );
     167                        if ( $this->handler ) {
     168                                $options['handler'] = \GuzzleHttp\HandlerStack::create( $this->handler );
     169                        }
     170
     171                        $http     = new \GuzzleHttp\Client( \Helper::setGuzzleDefaultOptions( $options ) );
    151172                        $response = $http->post(
    152173                                $this->base_url . ltrim( $endpoint, '/' ),
    … …  
    173194                }
    174195
     196                if ( self::PAYLOAD_TOO_LARGE === $status ) {
     197                        throw new PayloadTooLargeException( 'Request to ' . $endpoint . ' was too large: ' . strlen( $body ) . ' bytes.' );
     198                }
     199
    175200                if ( $status < 200 || $status > 299 ) {
    176201                        throw new RuntimeException( 'Request to ' . $endpoint . ' got a ' . $status . ' response.' );
    177202                }
     203
     204                $data = json_decode( trim( (string) $response->getBody() ), true );
     205
     206                return is_array( $data ) ? $data : array();
    178207        }
    179208}
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php

    r15261 r15344  
    1111
    1212use App\Conversation;
     13use App\Thread;
    1314use App\User;
     15use Carbon\Carbon;
    1416use Modules\WPOrgSSO\Entities\Account;
    1517
    … …  
    1719 * Builds the webhook payload for a conversation event.
    1820 *
    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.
    2023 */
    2124final class EventPayload {
    2225
    2326        /**
     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        /**
    2495         * Builds the payload.
     96         *
     97         * The conversation is added when the event is sent; see refresh().
    2598         *
    2699         * @param string       $event        Event name: the FreeScout hook that fired.
    27100         * @param Conversation $conversation Conversation the event happened on.
    28101         * @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.
    29103         * @return array
    30104         */
    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(
    35107                        'event'        => $event,
    36108                        'conversation' => array(
    37109                                'id' => (int) $conversation->id,
    38110                        ),
    39                         'mailbox'      => array(
    40                                 'id'   => (int) $conversation->mailbox_id,
    41                                 'name' => $mailbox ? (string) $mailbox->name : '',
    42                         ),
     111                        'mailbox'      => self::mailbox( $conversation ),
    43112                        'agent'        => $agent ? array(
    44113                                'id'             => (int) $agent->id,
    … …  
    50119
    51120        /**
     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        /**
    52347         * The WordPress.org account WPOrgSSO connected an agent to, which contributor stats credit.
    53348         *
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/module.json

    r15244 r15344  
    44    "img": "/modules/wporgwebhooks/img/icon.svg",
    55    "description": "Sends conversation events to WordPress.org, which records them as contributor stats.",
    6     "version": "1.0.0",
     6    "version": "1.1.0",
    77    "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks",
    88    "author": "WordPress.org",
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/EventForwardingTest.php

    r15261 r15344  
    1717use Modules\WPOrgWebhooks\Jobs\SendEvent;
    1818use Modules\WPOrgWebhooks\Providers\WPOrgWebhooksServiceProvider;
     19use Modules\WPOrgWebhooks\Services\EventPayload;
    1920use WordPressdotorg\FreeScout\Tests\TestCase;
    2021
    … …  
    171172                $this->assert_queued( 'conversation.merged', (string) $this->agent->email );
    172173                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&nbsp;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 &lt;script&gt; &amp;</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' ) ) );
    173418        }
    174419
    … …  
    255500
    256501                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 );
    257515        }
    258516
  • sites/trunk/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php

    r15244 r15344  
    1010namespace Modules\WPOrgWebhooks\Tests;
    1111
     12use App\Thread;
     13use GuzzleHttp\Handler\MockHandler;
     14use GuzzleHttp\Promise\PromiseInterface;
     15use GuzzleHttp\Psr7\Response;
    1216use Illuminate\Queue\Jobs\DatabaseJob;
    1317use Modules\WPOrgWebhooks\Jobs\SendEvent;
    1418use Modules\WPOrgWebhooks\Providers\WPOrgWebhooksServiceProvider;
     19use Modules\WPOrgWebhooks\Services\Client;
     20use Modules\WPOrgWebhooks\Services\EventPayload;
     21use Psr\Http\Message\RequestInterface;
    1522use WordPressdotorg\FreeScout\Tests\TestCase;
    1623
    … …  
    7279
    7380        /**
     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        /**
    74149         * Mocks the queued job an event runs as; FreeScout queues in the database.
    75150         *
    … …  
    88163         *
    89164         * @param DatabaseJob $queue_job Queued job the event runs as.
     165         * @param array       $payload   Event payload.
    90166         * @return void
    91167         */
    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 );
    94170                $event->setJob( $queue_job );
    95171                $event->handle();
Note: See TracChangeset for help on using the changeset viewer.