diff --git a/api.wordpress.org/public_html/dotorg/freescout/copies.php b/api.wordpress.org/public_html/dotorg/freescout/copies.php new file mode 100644 index 0000000000..464948fb99 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/copies.php @@ -0,0 +1,394 @@ +conversation->id ?? 0 ); + $email = $request->email ?? null; + if ( ! $id || ! is_object( $email ) ) { + return false; + } + + // Read from the primary database, not a lagging replica. + if ( is_callable( array( $wpdb, 'send_reads_to_masters' ) ) ) { + $wpdb->send_reads_to_masters(); + } + + // Spam and deleted conversations aren't kept; the current state wins over the event, as a deletion may be undone. + $deleted = isset( $email->state ) ? 'deleted' === $email->state : 'conversation.deleted' === ( $request->event ?? '' ); + if ( $deleted || 'spam' === ( $email->status ?? '' ) ) { + delete_emails( array_merge( array( $id ), ...array_values( get_replaced_ids( $request ) ) ) ); + + return false; + } + + return write_email( $request, $id ); +} + +/** + * Writes WordPress.org's copy of a conversation; see log_email(). + * + * @param object $request Request payload. + * @param int $id Conversation ID. + * @return bool Whether it needs all of the conversation's threads; see needs_all_threads(). + * + * @throws \RuntimeException If a write failed. + */ +function write_email( object $request, int $id ): bool { + global $wpdb; + + $email = $request->email; + $emails_table = "{$wpdb->base_prefix}helpscout"; + $replaced = get_replaced_ids( $request ); + $helpscout_id = (int) ( $request->helpscout_id ?? 0 ); + + $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $emails_table, $id ) ); + $has_helpscout = ! $row && $helpscout_id && $wpdb->get_var( $wpdb->prepare( 'SELECT 1 FROM %i WHERE id = %d', $emails_table, $helpscout_id ) ); + if ( needs_all_threads( $request, $row, (bool) $has_helpscout ) ) { + return true; + } + + /* + * The subject's mentions were added with the copy; the lookup costs queries, so only redo it when it changed. The + * column keeps 255 characters. + */ + $mailbox = $request->email->mailbox ?? ( $request->mailbox ?? null ); + $subject = (string) ( $email->subject ?? '' ); + $threads = (array) ( $email->threads ?? array() ); + $seen = $row && mb_substr( $subject, 0, 255 ) === (string) $row->subject && get_copy_mailbox_slug( $request ) === (string) $row->mailbox; + $mentioned = $seen && ! $threads ? array() : get_existing_slugs( + get_plugin_or_theme_from_email( + (object) array( + 'conversation' => (object) array( 'subject' => $seen ? '' : $subject ), + 'mailbox' => $mailbox, + 'threads' => $threads, + ) + ) + ); + + $user_id = (int) ( $row->user_id ?? 0 ); + if ( ! $user_id ) { + $user = get_sender_user( (object) array( 'sender' => $email->sender ?? null ) ); + $user_id = $user ? (int) $user->ID : 0; + } + + $data = get_email_row( $request, $row, $user_id ); + $columns = array_keys( $data ); + + // In an order that the event, sent again after a failed write, finishes. + $replaced_ids = array_merge( ...array_values( $replaced ) ); + take_over_meta( array_fill_keys( $replaced_ids, $id ) ); + add_copy_meta( $id, $replaced['helpscout'], $mentioned ); + + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The placeholders are added for each column; one statement, so two events for a new conversation at once don't both insert it. + check_write( + $wpdb->query( + $wpdb->prepare( + 'INSERT INTO %i (' . implode( ', ', array_fill( 0, count( $columns ), '%i' ) ) . ') VALUES (' . implode( ', ', array_fill( 0, count( $data ), '%s' ) ) . ') ON DUPLICATE KEY UPDATE ' . implode( ', ', array_fill( 0, count( $columns ) - 1, '%i = VALUES( %i )' ) ), + array_merge( + array( $emails_table ), + $columns, + array_values( $data ), + // Every column but the ID, twice: name, and name again for VALUES(). + ...array_map( + static function ( string $column ): array { + return array( $column, $column ); + }, + array_values( array_diff( $columns, array( 'id' ) ) ) + ) + ) + ) + ) + ); + // phpcs:enable + + delete_emails( $replaced_ids ); + + return false; +} + +/** + * The slugs of plugins and themes that exist, as the copy only keeps those. + * + * @param array $mentioned Slugs, by type: plugins, and themes. + * @return array + */ +function get_existing_slugs( array $mentioned ): array { + global $wpdb; + + $sites = array( + 'plugins' => array( WPORG_PLUGIN_DIRECTORY_BLOGID, 'plugin' ), + 'themes' => array( WPORG_THEME_DIRECTORY_BLOGID, 'repopackage' ), + ); + + foreach ( $mentioned as $type => $slugs ) { + if ( ! isset( $sites[ $type ] ) || ! $slugs ) { + unset( $mentioned[ $type ] ); + continue; + } + + $slugs = array_values( array_unique( array_map( 'strval', $slugs ) ) ); + + /* + * Not get_posts(): its "any" status only means statuses this request registers, and the directories' own, like a + * plugin in review's, aren't registered here. + */ + switch_to_blog( $sites[ $type ][0] ); + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The placeholders are added for each slug. + $mentioned[ $type ] = $wpdb->get_col( + $wpdb->prepare( + "SELECT post_name FROM %i WHERE post_type = %s AND post_status NOT IN ( 'trash', 'auto-draft', 'inherit' ) AND post_name IN (" . implode( ',', array_fill( 0, count( $slugs ), '%s' ) ) . ')', + array_merge( array( $wpdb->posts, $sites[ $type ][1] ), $slugs ) + ) + ); + // phpcs:enable + restore_current_blog(); + } + + return array_filter( $mentioned ); +} + +/** + * A conversation's row in WordPress.org's copy, whose columns and table name are HelpScout's. + * + * @param object $request Webhook payload, with the conversation in `email`; see WPOrgWebhooks' EventPayload. + * @param object|null $row Its current row, if any. + * @param int $user_id The sender's WordPress.org user ID, 0 if unknown. + * @return array + */ +function get_email_row( object $request, ?object $row, int $user_id ): array { + $email = $request->email ?? new \stdClass(); + $sender = $email->sender ?? new \stdClass(); + $address = (string) ( $sender->email ?? '' ); + $name = trim( ( $sender->first_name ?? '' ) . ' ' . ( $sender->last_name ?? '' ) ); + + $created = (int) strtotime( (string) ( $email->created_at ?? '' ) ); + $created = $created ? $created : (int) strtotime( (string) ( $row->created ?? '' ) ); + $closed = (int) strtotime( (string) ( $email->closed_at ?? '' ) ); + $updated = (int) strtotime( (string) ( $email->user_updated_at ?? '' ) ); + $times = array_filter( array( $created, $updated, $closed ) ); + + return array( + 'id' => (int) ( $request->conversation->id ?? 0 ), + 'number' => (int) ( $email->number ?? 0 ), + 'user_id' => $user_id, + 'mailbox' => get_copy_mailbox_slug( $request ), + 'status' => (string) ( $email->status ?? '' ), + 'email' => $address ? ( $name ? "{$name} <{$address}>" : $address ) : (string) ( $row->email ?? '' ), + 'subject' => (string) ( $email->subject ?? ( $row->subject ?? '' ) ), + 'preview' => (string) ( $email->preview ?? ( $row->preview ?? '' ) ), + 'created' => gmdate( 'Y-m-d H:i:s', $created ? $created : time() ), + 'closed' => $closed ? gmdate( 'Y-m-d H:i:s', $closed ) : '', + 'modified' => gmdate( 'Y-m-d H:i:s', $times ? max( $times ) : time() ), + ); +} + +/** + * The slug of the conversation's current mailbox, which the copy keeps, rather than the event's, which stats count. + * + * @param object $request Webhook payload. + * @return string + */ +function get_copy_mailbox_slug( object $request ): string { + return sanitize_title( (string) ( $request->email->mailbox->name ?? ( $request->mailbox->name ?? '' ) ) ); +} + +/** + * The copies an event's conversation replaces: one merged into it, and the HelpScout conversations it, or the one + * merged into it, were imported from. + * + * @param object $request Webhook payload. + * @return array[] FreeScout's IDs, under `freescout`, and HelpScout's, under `helpscout`. + */ +function get_replaced_ids( object $request ): array { + return array( + 'freescout' => array_values( array_filter( array( (int) ( $request->merged_id ?? 0 ) ) ) ), + 'helpscout' => array_values( + array_unique( + array_filter( + array( + (int) ( $request->helpscout_id ?? 0 ), + (int) ( $request->merged_helpscout_id ?? 0 ), + ) + ) + ) + ), + ); +} + +/** + * Whether copying a conversation needs all of its threads: the copy has neither it nor its HelpScout conversation, and + * the event carries only the newest. + * + * @param object $request Webhook payload. + * @param object|null $row The conversation's row in the copy, if it has one. + * @param bool $has_helpscout Whether the copy has the HelpScout conversation it was imported from. + * @return bool + */ +function needs_all_threads( object $request, ?object $row, bool $has_helpscout ): bool { + return ! $row && ! $has_helpscout && empty( $request->email->all_threads ); +} + +/** + * Moves the plugins and themes of copies to the copies that replace them. Pairs a copy has already stay behind, and go + * with the copy it replaces. + * + * @param int[] $into ID of the copy that replaces each, by the ID of the copy it replaces. + * @return void + * + * @throws \RuntimeException If the write failed. + */ +function take_over_meta( array $into ): void { + global $wpdb; + + if ( ! $into ) { + return; + } + + $args = array( "{$wpdb->base_prefix}helpscout_meta" ); + foreach ( $into as $replaced_id => $id ) { + array_push( $args, $replaced_id, $id ); + } + array_push( $args, ...array_keys( $into ) ); + + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The placeholders are added for each ID. + check_write( $wpdb->query( $wpdb->prepare( 'UPDATE IGNORE %i SET helpscout_id = CASE helpscout_id ' . implode( ' ', array_fill( 0, count( $into ), 'WHEN %d THEN %d' ) ) . ' END WHERE helpscout_id IN (' . implode( ',', array_fill( 0, count( $into ), '%d' ) ) . ')', $args ) ) ); +} + +/** + * Adds a FreeScout copy's meta, skipping pairs it has: its helpdesk, the HelpScout conversations it replaces, and the + * plugins and themes it mentions. + * + * @param int $id Conversation ID. + * @param int[] $helpscout_ids HelpScout conversations it replaces. + * @param array[] $mentioned Plugins' and themes' slugs, by type. + * @return void + * + * @throws \RuntimeException If the write failed. + */ +function add_copy_meta( int $id, array $helpscout_ids, array $mentioned ): void { + global $wpdb; + + $pairs = array( array( 'helpdesk', 'freescout' ) ); + foreach ( $helpscout_ids as $helpscout_id ) { + $pairs[] = array( 'helpscout_conversation', (string) $helpscout_id ); + } + foreach ( $mentioned as $type => $slugs ) { + foreach ( $slugs as $slug ) { + $pairs[] = array( (string) $type, (string) $slug ); + } + } + + insert_meta( + array_map( + static function ( array $pair ) use ( $id ): array { + return array( $id, $pair[0], $pair[1] ); + }, + $pairs + ) + ); +} + +/** + * Adds meta to copies, skipping pairs they have. + * + * @param array[] $rows Each a copy's ID, a meta key, and its value. + * @return void + * + * @throws \RuntimeException If the write failed. + */ +function insert_meta( array $rows ): void { + global $wpdb; + + if ( ! $rows ) { + return; + } + + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The placeholders are added for each row. + check_write( $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i ( helpscout_id, meta_key, meta_value ) VALUES ' . implode( ', ', array_fill( 0, count( $rows ), '( %d, %s, %s )' ) ), array_merge( array( "{$wpdb->base_prefix}helpscout_meta" ), ...$rows ) ) ) ); +} + +/** + * Deletes conversations from WordPress.org's copy, with their meta. + * + * @param int[] $ids Conversation IDs; HelpScout's for the copies of its conversations. + * @return void + * + * @throws \RuntimeException If a deletion failed. + */ +function delete_emails( array $ids ): void { + global $wpdb; + + if ( ! $ids ) { + return; + } + + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The placeholders are added for each ID. + check_write( $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( "{$wpdb->base_prefix}helpscout" ), $ids ) ) ) ); + check_write( $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE helpscout_id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( "{$wpdb->base_prefix}helpscout_meta" ), $ids ) ) ) ); + // phpcs:enable +} + +/** + * What switching an imported conversation does to its copy. + * + * @param object $copy The imported conversation: helpscout_id, id, number, status, and delete. + * @param bool $has_helpscout Whether the copy has the HelpScout conversation. + * @param bool $has_freescout Whether it has the FreeScout one. + * @return string `skip`; `delete` both copies (deleted or spam); `rename` the HelpScout copy to the FreeScout ID; or + * `merge` it into the FreeScout copy. + */ +function plan_copy_replacement( object $copy, bool $has_helpscout, bool $has_freescout ): string { + $helpscout_id = (int) ( $copy->helpscout_id ?? 0 ); + $id = (int) ( $copy->id ?? 0 ); + if ( $helpscout_id <= 0 || $id <= 0 || $helpscout_id === $id ) { + return 'skip'; + } + + if ( ! empty( $copy->delete ) ) { + return $has_helpscout || $has_freescout ? 'delete' : 'skip'; + } + + if ( ! $has_helpscout ) { + return 'skip'; + } + + return $has_freescout ? 'merge' : 'rename'; +} + +/** + * Throws if a write failed: wpdb returns false instead. + * + * @param mixed $result What wpdb returned. + * @return void + * + * @throws \RuntimeException If it failed. + */ +function check_write( mixed $result ): void { + global $wpdb; + + if ( false === $result ) { + throw new \RuntimeException( 'Could not write the copy: ' . (string) $wpdb->last_error ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Logged, not shown. + } +} diff --git a/api.wordpress.org/public_html/dotorg/freescout/replace-copies.php b/api.wordpress.org/public_html/dotorg/freescout/replace-copies.php new file mode 100644 index 0000000000..8473961d91 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/replace-copies.php @@ -0,0 +1,121 @@ +send_reads_to_masters(); + } + + $emails_table = "{$wpdb->base_prefix}helpscout"; + // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The placeholders are added for each ID. + $existing = array_fill_keys( array_map( 'intval', $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE id IN (' . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( array( $emails_table ), $ids ) ) ) ), true ); + $replaced = 0; + $into = array(); + $meta = array(); + $renames = array(); + $gone = array(); + + foreach ( $copies as $copy ) { + $helpscout_id = (int) ( $copy->helpscout_id ?? 0 ); + $id = (int) ( $copy->id ?? 0 ); + $action = plan_copy_replacement( $copy, isset( $existing[ $helpscout_id ] ), isset( $existing[ $id ] ) ); + if ( 'skip' === $action ) { + continue; + } + + ++$replaced; + unset( $existing[ $helpscout_id ] ); + $gone[] = $helpscout_id; + if ( 'delete' === $action ) { + unset( $existing[ $id ] ); + $gone[] = $id; + continue; + } + + $into[ $helpscout_id ] = $id; + array_push( $meta, array( $id, 'helpdesk', 'freescout' ), array( $id, 'helpscout_conversation', (string) $helpscout_id ) ); + if ( 'rename' === $action ) { + // What HelpScout's webhook last wrote may be out of date, as it no longer runs for the mailbox. + $renames[ $helpscout_id ] = array( + 'id' => $id, + 'number' => (int) ( $copy->number ?? 0 ), + 'status' => (string) ( $copy->status ?? '' ), + ); + $existing[ $id ] = true; + } + } + + take_over_meta( $into ); + insert_meta( $meta ); + if ( $renames ) { + $args = array( $emails_table ); + $sets = array(); + // The ID goes last: later assignments see the row's new values. + $formats = array( + 'number' => '%d', + 'status' => '%s', + 'id' => '%d', + ); + foreach ( $formats as $column => $format ) { + $sets[] = "{$column} = CASE id " . implode( ' ', array_fill( 0, count( $renames ), "WHEN %d THEN {$format}" ) ) . ' END'; + foreach ( $renames as $helpscout_id => $columns ) { + array_push( $args, $helpscout_id, $columns[ $column ] ); + } + } + array_push( $args, ...array_keys( $renames ) ); + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- The placeholders are added for each copy; $sets holds placeholders and column names. + check_write( $wpdb->query( $wpdb->prepare( 'UPDATE %i SET ' . implode( ', ', $sets ) . ' WHERE id IN (' . implode( ',', array_fill( 0, count( $renames ), '%d' ) ) . ')', $args ) ) ); + } + delete_emails( $gone ); + + return $replaced; +} + +$request = get_request( basename( __FILE__ ) ); + +try { + $replaced = replace_copies( (array) ( $request->copies ?? array() ) ); +} catch ( \Throwable $e ) { + trigger_error( 'FreeScout copies failed: ' . $e->getMessage(), E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.Security.EscapeOutput.OutputNotEscaped -- Logged, not shown. + status_header( 503 ); + exit; +} + +wp_send_json( array( 'replaced' => $replaced ) ); diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php index 37e977908b..ad27668857 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/bootstrap.php @@ -51,6 +51,16 @@ function wp_strip_all_tags( string $text ): string { return trim( (string) preg_replace( '/<[^>]*>/', '', $text ) ); } +/** + * Makes a slug, like WordPress's sanitize_title(), for the plain titles the tests use. + * + * @param string $title Title. + * @return string + */ +function sanitize_title( string $title ): string { + return trim( (string) preg_replace( '/[^a-z0-9]+/', '-', strtolower( $title ) ), '-' ); +} + /** * Encodes JSON, like WordPress's wp_json_encode(). * @@ -104,3 +114,4 @@ function wp_cache_get( string $key, string $group = '', bool $force = false, ?bo } require_once dirname( __DIR__ ) . '/common.php'; +require_once dirname( __DIR__ ) . '/copies.php'; diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-copy-plan-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-copy-plan-test.php new file mode 100644 index 0000000000..8ecd4879a4 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-copy-plan-test.php @@ -0,0 +1,151 @@ +assertSame( + array( + 'freescout' => array( self::MERGED_ID ), + 'helpscout' => array( self::HELPSCOUT_ID, self::MERGED_HELPSCOUT_ID ), + ), + get_replaced_ids( $this->request() ) + ); + + $this->assertSame( + array( + 'freescout' => array(), + 'helpscout' => array(), + ), + get_replaced_ids( (object) array( 'event' => 'conversation.user_replied' ) ) + ); + } + + /** + * Only a conversation the copy has nothing of yet needs all of its threads, unless the event carries them. + * + * @return void + */ + public function test_asks_for_all_threads_only_for_a_new_copy(): void { + $request = (object) array( 'email' => (object) array( 'all_threads' => false ) ); + + $this->assertTrue( needs_all_threads( $request, null, false ) ); + $this->assertFalse( needs_all_threads( $request, (object) array( 'id' => self::ID ), false ) ); + $this->assertFalse( needs_all_threads( $request, null, true ) ); + + $request->email->all_threads = true; + $this->assertFalse( needs_all_threads( $request, null, false ) ); + } + + /** + * Pointing the copy at an imported conversation renames HelpScout's copy, or merges it into FreeScout's; one + * deleted, or spam, goes; one already pointed at is left alone. + * + * @return void + */ + public function test_plans_replacing_a_copy(): void { + $copy = (object) array( + 'helpscout_id' => self::HELPSCOUT_ID, + 'id' => self::ID, + 'number' => 1234, + 'delete' => false, + ); + + $this->assertSame( 'rename', plan_copy_replacement( $copy, true, false ) ); + $this->assertSame( 'merge', plan_copy_replacement( $copy, true, true ) ); + $this->assertSame( 'skip', plan_copy_replacement( $copy, false, true ) ); + $this->assertSame( 'skip', plan_copy_replacement( $copy, false, false ) ); + + $copy->delete = true; + $this->assertSame( 'delete', plan_copy_replacement( $copy, true, false ) ); + $this->assertSame( 'delete', plan_copy_replacement( $copy, false, true ) ); + $this->assertSame( 'skip', plan_copy_replacement( $copy, false, false ) ); + + $copy->id = self::HELPSCOUT_ID; + $this->assertSame( 'skip', plan_copy_replacement( $copy, true, true ) ); + } + + /** + * The copy keeps a conversation in the mailbox it's in now; events from before it moved still name theirs, which + * the stats count. + * + * @return void + */ + public function test_copy_takes_the_current_mailbox(): void { + $request = json_decode( + (string) wp_json_encode( + array( + 'mailbox' => array( 'name' => 'Plugins' ), + 'email' => array( 'mailbox' => array( 'name' => 'Themes' ) ), + ) + ) + ); + + $this->assertSame( 'themes', get_copy_mailbox_slug( $request ) ); + $this->assertSame( 'plugins', get_mailbox_slug( $request ) ); + + unset( $request->email->mailbox ); + $this->assertSame( 'plugins', get_copy_mailbox_slug( $request ) ); + } + + /** + * A merge event of a conversation imported from HelpScout. + * + * @return object + */ + private function request(): object { + return (object) array( + 'event' => 'conversation.merged', + 'conversation' => (object) array( 'id' => self::ID ), + 'merged_id' => self::MERGED_ID, + 'helpscout_id' => self::HELPSCOUT_ID, + 'merged_helpscout_id' => self::MERGED_HELPSCOUT_ID, + ); + } +} diff --git a/api.wordpress.org/public_html/dotorg/freescout/tests/class-email-row-test.php b/api.wordpress.org/public_html/dotorg/freescout/tests/class-email-row-test.php new file mode 100644 index 0000000000..c68ac9ede1 --- /dev/null +++ b/api.wordpress.org/public_html/dotorg/freescout/tests/class-email-row-test.php @@ -0,0 +1,131 @@ +assertSame( + array( + 'id' => 42, + 'number' => 1234, + 'user_id' => 7, + 'mailbox' => 'plugins', + 'status' => 'closed', + 'email' => 'Jane Author ', + 'subject' => '[WordPress Plugin Directory] Review in Progress: My Plugin', + 'preview' => 'Thanks for your submission', + 'created' => '2026-08-01 10:00:00', + 'closed' => '2026-08-03 12:00:00', + 'modified' => '2026-08-03 12:00:00', + ), + get_email_row( $this->request(), null, 7 ) + ); + } + + /** + * What the event doesn't say is kept from the row, and a conversation that was never closed has no closing date. + * + * @return void + */ + public function test_keeps_what_the_event_does_not_say(): void { + $request = $this->request(); + unset( $request->email->sender, $request->email->created_at, $request->email->subject ); + $request->email->closed_at = null; + $request->email->user_updated_at = '2026-08-02T08:00:00Z'; + + $row = get_email_row( + $request, + (object) array( + 'email' => 'Old ', + 'subject' => 'Old subject', + 'created' => '2026-07-01 00:00:00', + ), + 0 + ); + + $this->assertSame( array( 'Old ', 'Old subject' ), array( $row['email'], $row['subject'] ) ); + $this->assertSame( array( '2026-07-01 00:00:00', '', '2026-08-02 08:00:00' ), array( $row['created'], $row['closed'], $row['modified'] ) ); + } + + /** + * A sender without a name is their address. + * + * @return void + */ + public function test_sender_without_a_name_is_their_address(): void { + $request = $this->request(); + $request->email->sender->first_name = ''; + $request->email->sender->last_name = ''; + + $this->assertSame( 'jane@example.org', get_email_row( $request, null, 0 )['email'] ); + } + + /** + * The row is in the mailbox the conversation is in now, rather than the one a late event happened in. + * + * @return void + */ + public function test_row_is_in_the_current_mailbox(): void { + $request = $this->request(); + $request->email->mailbox = (object) array( + 'id' => 4, + 'name' => 'Themes', + ); + + $this->assertSame( 'themes', get_email_row( $request, null, 0 )['mailbox'] ); + } + + /** + * A webhook payload, as WPOrgWebhooks' EventPayload builds it. + * + * @return object + */ + private function request(): object { + return json_decode( + (string) wp_json_encode( + array( + 'event' => 'conversation.user_replied', + 'conversation' => array( 'id' => 42 ), + 'mailbox' => array( + 'id' => 3, + 'name' => 'Plugins', + ), + 'email' => array( + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: My Plugin', + 'status' => 'closed', + 'preview' => 'Thanks for your submission', + 'created_at' => '2026-08-01T10:00:00Z', + 'closed_at' => '2026-08-03T12:00:00Z', + 'user_updated_at' => '2026-08-02T08:00:00Z', + 'sender' => array( + 'email' => 'jane@example.org', + 'emails' => array( 'jane@example.org' ), + 'first_name' => 'Jane', + 'last_name' => 'Author', + ), + 'threads' => array(), + ), + ) + ) + ); + } +} diff --git a/api.wordpress.org/public_html/dotorg/freescout/webhook.php b/api.wordpress.org/public_html/dotorg/freescout/webhook.php index 7c5380a07d..055692cb08 100644 --- a/api.wordpress.org/public_html/dotorg/freescout/webhook.php +++ b/api.wordpress.org/public_html/dotorg/freescout/webhook.php @@ -1,6 +1,6 @@ agent->id ) ) { + if ( empty( $request->agent->id ) || in_array( $event, UNCREDITED_EVENTS, true ) ) { return; } $username = (string) ( $request->agent->wporg_username ?? '' ); $wporg_user = $username ? get_user_by( 'login', $username ) : false; - $stat_user = $wporg_user ? $wporg_user->user_nicename : 'FS-' . (int) $request->agent->id; - $mailbox = get_mailbox_slug( $request ); - $fields = array( 'total' ); + if ( $wporg_user && in_array( (int) $wporg_user->ID, AUTOMATION_USER_IDS, true ) ) { + return; + } + + $stat_user = $wporg_user ? $wporg_user->user_nicename : 'FS-' . (int) $request->agent->id; + $mailbox = get_mailbox_slug( $request ); + $fields = array( 'total' ); if ( in_array( $event, REPLY_EVENTS, true ) ) { $fields[] = 'replies'; @@ -52,7 +72,25 @@ function contributor_stats( object $request ): void { } } -contributor_stats( get_request( basename( __FILE__ ) ) ); +$request = get_request( basename( __FILE__ ) ); + +// Before the stats, which an event sent again would count twice. FreeScout sends it again after a 503. +try { + $needs_all_threads = log_email( $request ); +} catch ( \Throwable $e ) { + 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. + status_header( 503 ); + exit; +} header( 'Content-Type: application/json; charset=utf-8' ); + +// FreeScout sends the event again at once, with all of the conversation's threads; that one counts. +if ( $needs_all_threads ) { + echo '{"threads":"all"}'; + exit; +} + +contributor_stats( $request ); + echo '{}'; diff --git a/api.wordpress.org/public_html/dotorg/helpscout/common.php b/api.wordpress.org/public_html/dotorg/helpscout/common.php index 92fc0c12f3..8c352b2e59 100644 --- a/api.wordpress.org/public_html/dotorg/helpscout/common.php +++ b/api.wordpress.org/public_html/dotorg/helpscout/common.php @@ -543,6 +543,27 @@ function get_mailbox_name( $mailbox_id_or_request ) { return sanitize_title( $mailbox->name ); } +/** + * Whether FreeScout's copy replaced a HelpScout conversation's, naming it under `helpscout_conversation`. + * + * Work still done on it in HelpScout must not bring HelpScout's copy back. + * + * @param int $helpscout_id HelpScout conversation ID. + * @return bool + */ +function is_moved_to_freescout( $helpscout_id ) { + global $wpdb; + + return (bool) $wpdb->get_var( + $wpdb->prepare( + 'SELECT 1 FROM %i WHERE meta_key = %s AND meta_value = %s LIMIT 1', + "{$wpdb->base_prefix}helpscout_meta", + 'helpscout_conversation', + (string) $helpscout_id + ) + ); +} + /** * Keep a cached copy of the received emails in the database for querying. * @@ -560,6 +581,10 @@ function log_email( $event, $request ) { return; } + if ( is_moved_to_freescout( $request->id ) ) { + return; + } + $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', "{$wpdb->base_prefix}helpscout", $request->id ) ); $meta = $row ? $wpdb->get_results( $wpdb->prepare( 'SELECT meta_key, meta_value FROM %i WHERE helpscout_id = %d', "{$wpdb->base_prefix}helpscout_meta", $request->id ), ARRAY_A ) : []; diff --git a/environments/freescout/mock-api/index.php b/environments/freescout/mock-api/index.php index aa60b03451..418b60559e 100644 --- a/environments/freescout/mock-api/index.php +++ b/environments/freescout/mock-api/index.php @@ -88,6 +88,12 @@ function respond( int $status, array|object $data ): void { respond( 200, new \stdClass() ); } +if ( 'replace-copies.php' === $endpoint ) { + $copies = (array) ( $request->copies ?? array() ); + file_put_contents( 'php://stderr', sprintf( "[replace-copies] %d conversations\n", count( $copies ) ) ); + respond( 200, array( 'replaced' => count( $copies ) ) ); +} + /* * What the real endpoints sent for obenland's WordPress.org account, whatever the sender, though searches are for the * sender's email, as they are there. Download links lose their review info, and the forum note and privacy request diff --git a/environments/plugin-directory/bin/database-tables.sql b/environments/plugin-directory/bin/database-tables.sql index 37306e9907..44e35393d8 100644 --- a/environments/plugin-directory/bin/database-tables.sql +++ b/environments/plugin-directory/bin/database-tables.sql @@ -1,14 +1,33 @@ -- Stub tables for local development. -- These tables exist outside WordPress on production but are needed locally. -CREATE TABLE IF NOT EXISTS `wp_helpscout_meta` ( - `helpscout_id` bigint(20) unsigned NOT NULL, - `meta_key` varchar(255) NOT NULL DEFAULT '', - `meta_value` varchar(255) NOT NULL DEFAULT '', - KEY `helpscout_id` (`helpscout_id`), - KEY `meta_key_value` (`meta_key`(191),`meta_value`(191)) +-- WordPress.org's copy of helpdesk conversations, which api.wordpress.org's webhooks fill. +CREATE TABLE IF NOT EXISTS `wp_helpscout` ( + `id` bigint(20) unsigned NOT NULL, + `number` int(10) unsigned NOT NULL DEFAULT 0, + `user_id` bigint(20) unsigned NOT NULL DEFAULT 0, + `mailbox` varchar(50) NOT NULL DEFAULT '', + `status` varchar(20) NOT NULL DEFAULT '', + `email` varchar(255) NOT NULL DEFAULT '', + `subject` varchar(255) NOT NULL DEFAULT '', + `preview` text, + `created` varchar(19) NOT NULL DEFAULT '', + `closed` varchar(19) NOT NULL DEFAULT '', + `modified` varchar(19) NOT NULL DEFAULT '', + PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- As on production. +CREATE TABLE IF NOT EXISTS `wp_helpscout_meta` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, + `helpscout_id` bigint(20) NOT NULL, + `meta_key` varchar(32) NOT NULL, + `meta_value` varchar(255) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `helpscout_id` (`helpscout_id`,`meta_key`,`meta_value`), + KEY `meta_key` (`meta_key`,`meta_value`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1; + CREATE TABLE IF NOT EXISTS `wp_svn_access` ( `path` varchar(255) NOT NULL DEFAULT '', `user` varchar(200) NOT NULL DEFAULT '0', diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 478ed2d786..eb6ad95414 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -10,11 +10,11 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `Modules//` — one directory per module (`module.json`, `Providers/`, `Http/`, `Resources/`, `Database/Migrations/`, `Public/`, `tests/`). - `WPOrgAkismet` — checks new conversations from senders with Akismet (`rest.akismet.com`, its own key), and reports agents' corrections. - - `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. + - `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. - `WPOrgSidebar` — WordPress.org panels in the conversation sidebar, loaded over AJAX from `api.wordpress.org/dotorg/freescout/`. - `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. - `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). - - `WPOrgWebhooks` — queues conversation events to `api.wordpress.org/dotorg/freescout/webhook.php`, which records contributor stats. + - `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. - `tests/` — shared PHPUnit bootstrap and base `TestCase`. Premium (paid) modules must never be committed here. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php index dbb63cc444..6669cb47cf 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/Controllers/ImportController.php @@ -18,6 +18,7 @@ use Illuminate\View\View; use Modules\WPOrgHelpScoutImport\Entities\Run; use Modules\WPOrgHelpScoutImport\Jobs\ImportPage; +use Modules\WPOrgHelpScoutImport\Services\Copies; use Modules\WPOrgHelpScoutImport\Services\HelpScout; use Modules\WPOrgHelpScoutImport\Services\People; use Modules\WPOrgHelpScoutImport\Services\SavedReplies; @@ -82,10 +83,54 @@ public function index(): View { 'numbering' => $sources ? self::numbering( $helpscout ) : null, // Imports bring saved replies only while there's somewhere to put them. 'replies' => SavedReplies::available(), + 'copies' => self::copies(), ) ); } + /** + * Switches a mailbox's conversations in WordPress.org's copy to FreeScout, or sends them again. + * + * @param Request $request Request. + * @return RedirectResponse + */ + public function point( Request $request ): RedirectResponse { + $mailbox = Mailbox::find( (int) $request->input( 'mailbox_id' ) ); + if ( ! $mailbox ) { + return self::back_with_error( __( 'Choose a FreeScout mailbox.' ) ); + } + + if ( ! Copies::available() ) { + return self::back_with_error( __( 'Pointing WordPress.org at FreeScout needs the WP.org Webhooks module, set up with WPORG_API_SECRET.' ) ); + } + + Copies::start( (int) $mailbox->id ); + + return redirect() + ->route( 'wporghelpscoutimport.index' ) + ->with( 'flash_success', __( 'Pointing WordPress.org at :name’s imported conversations.', array( 'name' => $mailbox->name ) ) ); + } + + /** + * The mailboxes conversations were imported into, and their switch. + * + * @return array[] Mailbox, total imported, and the switch, see Copies::get(). + */ + private static function copies(): array { + $copies = array(); + $ids = Run::query()->distinct()->pluck( 'mailbox_id' )->map( 'intval' )->all(); + + foreach ( Mailbox::query()->whereIn( 'id', $ids ? $ids : array( 0 ) )->orderBy( 'name' )->get() as $mailbox ) { + $copies[] = array( + 'mailbox' => $mailbox, + 'total' => Copies::total( (int) $mailbox->id ), + 'state' => Copies::get( (int) $mailbox->id ), + ); + } + + return $copies; + } + /** * Starts importing a HelpScout mailbox: everything, or what changed since its last finished import into the mailbox. * diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php index 15993d7898..5255005e21 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Http/routes.php @@ -22,6 +22,7 @@ static function (): void { Route::post( '/helpscout-import/users/refresh', 'AgentsController@refresh' )->name( 'wporghelpscoutimport.agents.refresh' ); Route::get( '/helpscout-import/users/export', 'AgentsController@export' )->name( 'wporghelpscoutimport.agents.export' ); Route::post( '/helpscout-import/users/connect', 'AgentsController@connect' )->name( 'wporghelpscoutimport.agents.connect' ); + Route::post( '/helpscout-import/copies', 'ImportController@point' )->name( 'wporghelpscoutimport.copies' ); Route::post( '/helpscout-import/{id}/pause', 'ImportController@pause' )->name( 'wporghelpscoutimport.pause' ); Route::post( '/helpscout-import/{id}/resume', 'ImportController@resume' )->name( 'wporghelpscoutimport.resume' ); Route::post( '/helpscout-import/{id}/cancel', 'ImportController@cancel' )->name( 'wporghelpscoutimport.cancel' ); diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php index ddf9b27140..c9fb4c7e16 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ImportPage.php @@ -17,6 +17,7 @@ use Modules\WPOrgHelpScoutImport\Entities\Run; use Modules\WPOrgHelpScoutImport\Exceptions\ApiError; use Modules\WPOrgHelpScoutImport\Exceptions\RateLimited; +use Modules\WPOrgHelpScoutImport\Services\Copies; use Modules\WPOrgHelpScoutImport\Services\HelpScout; use Modules\WPOrgHelpScoutImport\Services\Importer; use Modules\WPOrgHelpScoutImport\Services\People; @@ -260,7 +261,10 @@ static function () use ( $attempts ): bool { if ( $run->is_retry() || ! $page['conversations'] ) { $run->status = Run::STATUS_DONE; $run->finished_at = Carbon::now(); - $this->save( $run ); + if ( $this->save( $run ) ) { + // Send what this run imported, if the mailbox switched. + Copies::send_again( (int) $run->mailbox_id ); + } return; } diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ReplaceCopies.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ReplaceCopies.php new file mode 100644 index 0000000000..4146f57e57 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Jobs/ReplaceCopies.php @@ -0,0 +1,133 @@ +mailbox_id = $mailbox_id; + $this->run = $run; + $this->after_id = $after_id; + } + + /** + * Sends the next batch, and queues the one after. + * + * Failures don't throw, but are noted for the import page, which can send everything again. + * + * @return void + */ + public function handle(): void { + $state = Copies::get( $this->mailbox_id ); + if ( ! $state || ( $state['run'] ?? '' ) !== $this->run ) { + return; + } + + try { + $batch = Copies::batch( $this->mailbox_id, $this->after_id, self::BATCH ); + if ( $batch->isEmpty() ) { + Copies::note( $this->mailbox_id, $this->run, array( 'finished_at' => Carbon::now()->toDateTimeString() ) ); + + return; + } + + Client::from_config( 60 )->post( + self::ENDPOINT, + array( 'copies' => $batch->map( array( Copies::class, 'request' ) )->values()->all() ) + ); + } catch ( \Throwable $e ) { + if ( $this->attempts() < $this->tries ) { + $this->release( self::RETRY_DELAY * $this->attempts() ); + + return; + } + + \Log::error( '[WPOrgHelpScoutImport] Could not point WordPress.org at mailbox ' . $this->mailbox_id . '\'s conversations: ' . $e->getMessage() ); + Copies::note( $this->mailbox_id, $this->run, array( 'error' => $e->getMessage() ) ); + + return; + } + + if ( Copies::note( $this->mailbox_id, $this->run, array( 'sent' => (int) $state['sent'] + $batch->count() ) ) ) { + self::dispatch( $this->mailbox_id, $this->run, (int) $batch->last()->id ); + } + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php index bbefa23b57..7c489e25b5 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Providers/WPOrgHelpScoutImportServiceProvider.php @@ -9,7 +9,10 @@ namespace Modules\WPOrgHelpScoutImport\Providers; +use App\Conversation; use Illuminate\Support\ServiceProvider; +use Modules\WPOrgHelpScoutImport\Entities\ImportedConversation; +use Modules\WPOrgHelpScoutImport\Services\Copies; use Modules\WPOrgHelpScoutImport\Services\HelpScout; /** @@ -119,6 +122,46 @@ static function ( $javascripts = array() ) { } ); + // Names the HelpScout conversation whose copy an imported one's replaces. + \Eventy::addFilter( + 'wporgwebhooks.helpscout_id', + static function ( mixed $helpscout_id = 0, mixed $conversation = null, mixed $conversation_id = 0 ): mixed { + // Deleted for good, the conversation is null, but its ID still finds the import. + $id = $conversation instanceof Conversation ? (int) $conversation->id : (int) $conversation_id; + + try { + if ( ! $helpscout_id && $id ) { + $helpscout_id = (int) ImportedConversation::query()->where( 'conversation_id', $id )->value( 'helpscout_id' ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgHelpScoutImport] Could not find the HelpScout conversation of ' . $id . ': ' . $e->getMessage() ); + } + + return $helpscout_id; + }, + 10, + 3 + ); + + // Imported conversations' events leave the copy alone until their mailbox switches. + \Eventy::addFilter( + 'wporgwebhooks.copy', + static function ( mixed $copy = true, mixed $conversation = null, mixed $conversation_id = 0, mixed $mailbox_id = 0 ): mixed { + $id = $conversation instanceof Conversation ? (int) $conversation->id : (int) $conversation_id; + $mailbox = $conversation instanceof Conversation ? (int) $conversation->mailbox_id : (int) $mailbox_id; + + try { + return $copy && ( ! $id || Copies::sends( $id, $mailbox ) ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgHelpScoutImport] Could not tell whether to copy conversation ' . $id . ': ' . $e->getMessage() ); + + return $copy; + } + }, + 10, + 4 + ); + // Highlights Manage while the pages are open. \Eventy::addFilter( 'menu.selected', diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 20ee8b3021..388dc9b8a3 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -39,7 +39,7 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's - Someone with a FreeScout user under another email gets a second one unless they're chosen on the Users page first: FreeScout can't merge users. Choosing someone after an import credits what's imported for them to the user chosen, keeping HelpScout's dates; with the Reports module on, it computes those conversations' metrics again. - 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. - **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. -- **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. +- **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. - 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. - The same goes for conversations still being worked on in HelpScout, while both run. - **HelpScout's rate limit:** the whole HelpScout account shares one limit, and wppluginsteam.org and the reviewers' tools use it too. The import leaves 100 requests a minute to them, and waits for the next minute when only those are left. A conversation too big to read in one minute's share is read again on its next try, waiting for the limit as it goes, but only while no outgoing email is waiting in FreeScout's queue. @@ -56,6 +56,15 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's - FreeScout's queue worker must be running. - 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. - 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. +- **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: + 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. + 2. Run the last import of the mailbox. + 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. + 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. + + 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. + + 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. - Saved replies, tags, and custom fields: switch on the Saved Replies, Tags, and Custom Fields modules before importing. - 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. - After the last mailbox has moved, switch the module off, and remove its code in a later deploy. Imported conversations keep HelpScout's numbers. Its tables map HelpScout's conversation IDs to FreeScout's: convert anything that still links to HelpScout before removing it. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php index b275d956be..ade6fe9903 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Resources/views/index.blade.php @@ -213,6 +213,54 @@ @endif + + @if ( count( $copies ) ) +

{{ __('WordPress.org') }}

+

{{ __('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.') }}

+ + + + + + + + + + + @foreach ( $copies as $copy ) + @php( $state = $copy['state'] ) + + + + + + + @endforeach + +
{{ __('Mailbox') }}{{ __('Imported') }}{{ __('WordPress.org points at') }}
{{ $copy['mailbox']->name }}{{ number_format( $copy['total'] ) }} + @if ( ! $state ) + {{ __('HelpScout') }} + @else + {{ __('FreeScout, since :date', [ 'date' => App\User::dateFormat( $state['switched_at'], 'M j, Y H:i' ) ]) }} +
+ @if ( $state['finished_at'] ) + {{ __(':sent sent, done :date', [ 'sent' => number_format( $state['sent'] ), 'date' => App\User::dateFormat( $state['finished_at'], 'M j, Y H:i' ) ]) }} + @else + {{ __(':sent of :total sent so far', [ 'sent' => number_format( $state['sent'] ), 'total' => number_format( $copy['total'] ) ]) }} + @endif + + @if ( '' !== $state['error'] ) +
{{ $state['error'] }} + @endif + @endif +
+
+ {{ csrf_field() }} + + +
+
+ @endif @endsection diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Copies.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Copies.php new file mode 100644 index 0000000000..a6fffb120f --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/Services/Copies.php @@ -0,0 +1,304 @@ +is_configured(); + } + + /** + * Switches a mailbox, and sends all of its imported conversations to WordPress.org; again if it switched already, + * which stops the send before. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @return void + */ + public static function start( int $mailbox_id ): void { + $run = Str::random( 16 ); + self::change( + $mailbox_id, + static function ( ?array $state ) use ( $run ): array { + return array( + 'switched_at' => (string) ( $state['switched_at'] ?? Carbon::now()->toDateTimeString() ), + 'run' => $run, + 'sent' => 0, + 'finished_at' => null, + 'error' => '', + ); + } + ); + + ReplaceCopies::dispatch( $mailbox_id, $run ); + } + + /** + * Sends a switched mailbox's imported conversations again, for what an import added or updated. Never throws: it + * runs as an import finishes. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @return void + */ + public static function send_again( int $mailbox_id ): void { + try { + if ( self::get( $mailbox_id ) && self::available() ) { + self::start( $mailbox_id ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgHelpScoutImport] Could not point WordPress.org at mailbox ' . $mailbox_id . '\'s new conversations: ' . $e->getMessage() ); + } + } + + /** + * Notes how far a send got, or that it's done, or failed. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @param string $run The send's token. + * @param array $changes Keys of the switch to change: sent, finished_at, or error. + * @return bool False if a later send took over. + */ + public static function note( int $mailbox_id, string $run, array $changes ): bool { + return null !== self::change( + $mailbox_id, + static function ( ?array $state ) use ( $run, $changes ): ?array { + return $state && ( $state['run'] ?? '' ) === $run ? $changes + $state : null; + } + ); + } + + /** + * Changes a mailbox's switch under a row lock, so a send and the next one's start don't undo each other. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @param callable $change Takes the switch, or null, and returns it changed, or null to leave it. + * @return array|null The switch as changed; null if it wasn't. + */ + private static function change( int $mailbox_id, callable $change ): ?array { + $name = self::OPTION . $mailbox_id; + + try { + Option::query()->firstOrCreate( array( 'name' => $name ), array( 'value' => '' ) ); + } catch ( QueryException $e ) { + // Another process created it first. + unset( $e ); + } + + return \DB::transaction( + static function () use ( $name, $change ): ?array { + $option = Option::query()->where( 'name', $name )->lockForUpdate()->first(); + if ( ! $option ) { + return null; + } + + $state = Option::maybeUnserialize( $option->value ); + $changed = $change( is_array( $state ) ? $state : null ); + if ( null === $changed ) { + return null; + } + + $option->value = Option::maybeSerialize( $changed ); + $option->save(); + + return $changed; + } + ); + } + + /** + * How many conversations were imported into a mailbox. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @return int + */ + public static function total( int $mailbox_id ): int { + return self::imported( $mailbox_id )->count(); + } + + /** + * The next conversations imported into a mailbox to send, in the order they were imported. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @param int $after_id ID of the last one sent. + * @param int $limit How many. + * @return Collection With helpscout_id, conversation_id, and their conversation's number, state, and status. + */ + public static function batch( int $mailbox_id, int $after_id, int $limit ): Collection { + $table = ( new ImportedConversation() )->getTable(); + + return self::imported( $mailbox_id ) + ->where( $table . '.id', '>', $after_id ) + ->orderBy( $table . '.id' ) + ->limit( $limit ) + ->get( + array( + $table . '.id', + $table . '.helpscout_id', + $table . '.conversation_id', + 'conversations.number', + 'conversations.state', + 'conversations.status', + ) + ); + } + + /** + * What WordPress.org is told about an imported conversation: the FreeScout conversation that replaces its HelpScout + * copy, or that the copy goes, as it's deleted or spam. + * + * One merged away is sent as the conversation it went into, which takes over its copy's plugins and themes. + * + * @param ImportedConversation $imported An imported conversation, as batch() reads it. + * @return array HelpScout's ID, FreeScout's ID, number, and status, and whether to delete the copy. + */ + public static function request( ImportedConversation $imported ): array { + $request = array( + 'helpscout_id' => (int) $imported->helpscout_id, + 'id' => (int) $imported->conversation_id, + 'number' => (int) $imported->number, + 'status' => EventPayload::STATUSES[ (int) $imported->status ] ?? 'active', + 'delete' => self::gone( (int) $imported->state, (int) $imported->status ), + ); + + $into = Conversation::STATE_DELETED === (int) $imported->state ? self::merged_into( (int) $imported->conversation_id ) : null; + if ( $into ) { + $request = array( + 'id' => (int) $into->id, + 'number' => (int) $into->number, + 'status' => EventPayload::STATUSES[ (int) $into->status ] ?? 'active', + 'delete' => self::gone( (int) $into->state, (int) $into->status ), + ) + $request; + } + + return $request; + } + + /** + * Whether a conversation's copy goes: it's deleted, or spam. + * + * @param int $state Its state. + * @param int $status Its status. + * @return bool + */ + private static function gone( int $state, int $status ): bool { + return Conversation::STATE_DELETED === $state || Conversation::STATUS_SPAM === $status; + } + + /** + * The conversation one was merged into, following further merges. + * + * @param int $conversation_id Conversation ID. + * @return Conversation|null Null if it wasn't merged away, or the one it went into was deleted for good. + */ + private static function merged_into( int $conversation_id ): ?Conversation { + $into = null; + + // Merges are manual, so chains are short. + for ( $hops = 0; $hops < 10; $hops++ ) { + $merges = Thread::query() + ->where( 'conversation_id', $conversation_id ) + ->where( 'type', Thread::TYPE_LINEITEM ) + ->where( 'action_type', Thread::ACTION_TYPE_MERGED ) + ->orderBy( 'id', 'desc' ) + ->get(); + + $next = 0; + foreach ( $merges as $merge ) { + $next = (int) $merge->getMeta( Thread::META_MERGED_INTO_CONV, 0 ); + if ( $next ) { + break; + } + } + + $next_conversation = $next ? Conversation::find( $next ) : null; + if ( ! $next_conversation ) { + return $into; + } + + $into = $next_conversation; + $conversation_id = $next; + if ( Conversation::STATE_DELETED !== (int) $into->state ) { + return $into; + } + } + + return $into; + } + + /** + * The imported conversations in a mailbox. + * + * @param int $mailbox_id FreeScout mailbox ID. + * @return Builder + */ + private static function imported( int $mailbox_id ): Builder { + $table = ( new ImportedConversation() )->getTable(); + + return ImportedConversation::query() + ->join( 'conversations', 'conversations.id', '=', $table . '.conversation_id' ) + ->where( 'conversations.mailbox_id', $mailbox_id ); + } + + /** + * Whether a conversation's events carry it for WordPress.org's copy: an imported one's only once its mailbox switches. + * + * @param int $conversation_id Conversation ID. + * @param int $mailbox_id Its mailbox's ID. + * @return bool + */ + public static function sends( int $conversation_id, int $mailbox_id ): bool { + return null !== self::get( $mailbox_id ) + || ! ImportedConversation::query()->where( 'conversation_id', $conversation_id )->exists(); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json index b92540bd58..640b3ca3e9 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/module.json @@ -3,7 +3,7 @@ "alias": "wporghelpscoutimport", "img": "/modules/wporghelpscoutimport/img/icon.svg", "description": "Imports HelpScout mailboxes' conversations, so their history moves to FreeScout with them.", - "version": "1.0.0", + "version": "1.1.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgHelpScoutImport", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/CopiesTest.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/CopiesTest.php new file mode 100644 index 0000000000..c13c3b2587 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/CopiesTest.php @@ -0,0 +1,329 @@ +app->register( WPOrgWebhooksServiceProvider::class ); + $this->app->register( WPOrgHelpScoutImportServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + Queue::fake(); + \Option::$cache = array(); + $this->mailbox = $this->create_mailbox(); + $this->actingAs( $this->create_user( User::ROLE_ADMIN ) ); + } + + /** + * Forgets the switches the test saved, which the database forgets with it. + * + * @return void + */ + protected function tearDown(): void { + \Option::$cache = array(); + + parent::tearDown(); + } + + /** + * Imported conversations' events leave WordPress.org's copy alone until their mailbox switches; others' don't. + * + * @return void + */ + public function test_imported_conversations_are_copied_once_their_mailbox_switched(): void { + $imported = $this->import( 1000000028 ); + $new = $this->create_conversation( $this->mailbox, $this->create_sender() ); + + $this->assertFalse( \Eventy::filter( 'wporgwebhooks.copy', true, $imported ) ); + $this->assertTrue( \Eventy::filter( 'wporgwebhooks.copy', true, $new ) ); + + $this->post( route( 'wporghelpscoutimport.copies' ), array( 'mailbox_id' => $this->mailbox->id ) ); + + $this->assertTrue( \Eventy::filter( 'wporgwebhooks.copy', true, $imported ) ); + Queue::assertPushed( ReplaceCopies::class, 1 ); + } + + /** + * Each batch is sent, signed, to api.wordpress.org, which the local environment's mock answers, and the next is + * queued after it until none are left, counting what was sent. + * + * @return void + */ + public function test_sends_batches_until_done(): void { + config( + array( + 'wporgwebhooks.api_url' => 'http://mock-api:8080/', + 'wporgwebhooks.secret' => 'local-secret', + ) + ); + $this->import( 1000000028 ); + $this->import( 1000000029 ); + Copies::start( (int) $this->mailbox->id ); + $run = $this->send_token(); + $last = (int) ImportedConversation::query()->max( 'id' ); + + ( new ReplaceCopies( (int) $this->mailbox->id, $run ) )->handle(); + + Queue::assertPushed( + ReplaceCopies::class, + static function ( ReplaceCopies $job ) use ( $run, $last ): bool { + return $run === $job->run && $last === $job->after_id; + } + ); + $this->assertSame( array( 2, null ), array( $this->state()['sent'], $this->state()['finished_at'] ) ); + + ( new ReplaceCopies( (int) $this->mailbox->id, $run, $last ) )->handle(); + + Queue::assertPushed( ReplaceCopies::class, 2 ); + $this->assertNotNull( $this->state()['finished_at'] ); + } + + /** + * A batch WordPress.org refuses on its last try ends the send, noting why until it's sent again. + * + * @return void + */ + public function test_failed_batches_are_noted_until_sent_again(): void { + $this->import( 1000000028 ); + Copies::start( (int) $this->mailbox->id ); + + $queue_job = $this->createMock( DatabaseJob::class ); + $queue_job->method( 'attempts' )->willReturn( 3 ); + $job = new ReplaceCopies( (int) $this->mailbox->id, $this->send_token() ); + $job->setJob( $queue_job ); + $job->handle(); + + $this->assertNotSame( '', $this->state()['error'] ); + Queue::assertPushed( ReplaceCopies::class, 1 ); + + Copies::start( (int) $this->mailbox->id ); + + $this->assertSame( '', $this->state()['error'] ); + Queue::assertPushed( ReplaceCopies::class, 2 ); + } + + /** + * Imports finishing after the switch send everything again, which stops the send before and keeps the switch's + * date; before it, nothing is sent. + * + * @return void + */ + public function test_imports_after_the_switch_send_everything_again(): void { + config( array( 'wporgwebhooks.secret' => 'test-secret' ) ); + $this->import( 1000000028 ); + + Copies::send_again( (int) $this->mailbox->id ); + Queue::assertNotPushed( ReplaceCopies::class ); + + Copies::start( (int) $this->mailbox->id ); + $before = $this->state(); + Copies::send_again( (int) $this->mailbox->id ); + + $this->assertNotSame( $before['run'], $this->send_token() ); + $this->assertSame( $before['switched_at'], $this->state()['switched_at'] ); + Queue::assertPushed( ReplaceCopies::class, 2 ); + + ( new ReplaceCopies( (int) $this->mailbox->id, $before['run'] ) )->handle(); + + Queue::assertPushed( ReplaceCopies::class, 2 ); + } + + /** + * Copies of conversations deleted or spam go, HelpScout's too; others are pointed at. + * + * @return void + */ + public function test_deleted_and_spam_conversations_have_their_copies_deleted(): void { + $sent = $this->answer(); + $kept = $this->import( 1000000025 ); + $trashed = $this->import( 1000000026 ); + $spam = $this->import( 1000000027 ); + + $trashed->state = Conversation::STATE_DELETED; + $trashed->save(); + $spam->status = Conversation::STATUS_SPAM; + $spam->save(); + + Copies::start( (int) $this->mailbox->id ); + $this->run_batches(); + + $this->assertSame( + array( + (int) $kept->id => false, + (int) $trashed->id => true, + (int) $spam->id => true, + ), + array_column( $sent[0]['copies'], 'delete', 'id' ) + ); + } + + /** + * A conversation merged away before the switch is sent as the one it went into, following further merges; one + * merged into a conversation deleted since goes. + * + * @return void + */ + public function test_conversations_merged_away_are_sent_as_the_one_they_went_into(): void { + $sent = $this->answer(); + $agent = $this->create_user(); + $kept = $this->import( 1000000025 ); + $merged = $this->import( 1000000026 ); + $twice = $this->import( 1000000027 ); + $trashed = $this->import( 1000000028 ); + $lost = $this->import( 1000000029 ); + + $twice->mergeConversations( $merged, $agent ); + $kept->mergeConversations( $twice, $agent ); + $trashed->mergeConversations( $lost, $agent ); + $trashed->state = Conversation::STATE_DELETED; + $trashed->save(); + + Copies::start( (int) $this->mailbox->id ); + $this->run_batches(); + + $copies = array_column( $sent[0]['copies'], null, 'helpscout_id' ); + foreach ( array( 1000000026, 1000000027 ) as $helpscout_id ) { + $this->assertSame( + array( + 'id' => (int) $kept->id, + 'number' => (int) $kept->number, + 'status' => 'active', + 'delete' => false, + ), + array_intersect_key( $copies[ $helpscout_id ], array_flip( array( 'id', 'number', 'status', 'delete' ) ) ) + ); + } + $this->assertSame( array( (int) $kept->id, false ), array( $copies[1000000025]['id'], $copies[1000000025]['delete'] ) ); + $this->assertSame( array( (int) $trashed->id, true ), array( $copies[1000000029]['id'], $copies[1000000029]['delete'] ) ); + } + + /** + * Events of an imported conversation deleted for good before they went out leave the copy alone until its mailbox + * switches. + * + * @return void + */ + public function test_copy_filter_knows_conversations_deleted_since(): void { + $gone = $this->import( 1000000028 ); + $id = (int) $gone->id; + Conversation::deleteConversationsForever( array( $id ) ); + + $this->assertFalse( \Eventy::filter( 'wporgwebhooks.copy', true, null, $id, (int) $this->mailbox->id ) ); + $this->assertSame( 1000000028, \Eventy::filter( 'wporgwebhooks.helpscout_id', 0, null, $id ) ); + + Copies::start( (int) $this->mailbox->id ); + $this->assertTrue( \Eventy::filter( 'wporgwebhooks.copy', true, null, $id, (int) $this->mailbox->id ) ); + } + + /** + * Runs the next batch, as the queue would. + * + * @return void + */ + private function run_batches(): void { + ( new ReplaceCopies( (int) $this->mailbox->id, $this->send_token() ) )->handle(); + } + + /** + * The mailbox's switch. + * + * @return array + */ + private function state(): array { + return (array) Copies::get( (int) $this->mailbox->id ); + } + + /** + * The token of the mailbox's latest send. + * + * @return string + */ + private function send_token(): string { + return (string) ( $this->state()['run'] ?? '' ); + } + + /** + * Answers WordPress.org's requests, every one taken. + * + * @return \ArrayObject The payloads it was sent, as they arrive. + */ + private function answer(): \ArrayObject { + $sent = new \ArrayObject(); + $this->app->instance( + Client::class, + new Client( + 'https://api.wordpress.test/', + 'test-secret', + 5, + static function ( RequestInterface $request, array $options ) use ( $sent ): PromiseInterface { + $sent[] = json_decode( (string) $request->getBody(), true ); + + return ( new MockHandler( array( new Response( 200, array(), '{"replaced":1}' ) ) ) )( $request, $options ); + } + ) + ); + + return $sent; + } + + /** + * A conversation imported from HelpScout into the mailbox. + * + * @param int $helpscout_id HelpScout's ID for it. + * @return Conversation + */ + private function import( int $helpscout_id ): Conversation { + $conversation = $this->create_conversation( $this->mailbox, $this->create_sender() ); + ImportedConversation::query()->create( + array( + 'helpscout_id' => $helpscout_id, + 'helpscout_number' => (int) $conversation->number, + 'conversation_id' => (int) $conversation->id, + ) + ); + + return $conversation; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php index 66943527c5..00157ee1fc 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImportControllerTest.php @@ -16,6 +16,7 @@ use Modules\WPOrgHelpScoutImport\Entities\Agent; use Modules\WPOrgHelpScoutImport\Entities\Run; use Modules\WPOrgHelpScoutImport\Jobs\ImportPage; +use Modules\WPOrgHelpScoutImport\Services\Copies; require_once __DIR__ . '/ImportTestCase.php'; @@ -301,6 +302,33 @@ public function test_cancel_frees_the_mailbox(): void { $this->assertSame( 2, Run::query()->count() ); } + /** + * The page shows how far pointing WordPress.org at a mailbox's imported conversations got, and why it stopped. + * + * @return void + */ + public function test_shows_how_far_wordpress_org_was_pointed(): void { + $this->post( route( 'wporghelpscoutimport.start' ), $this->start_form() ); + $mailbox_id = (int) Run::query()->firstOrFail()->mailbox_id; + Copies::start( $mailbox_id ); + + $this->assertStringContainsString( '0 of 0 sent so far', $this->get( route( 'wporghelpscoutimport.index' ) )->getContent() ); + + Copies::note( + $mailbox_id, + (string) Copies::get( $mailbox_id )['run'], + array( + 'sent' => 3, + 'finished_at' => '2026-10-02 10:00:00', + 'error' => 'WordPress.org answered 503.', + ) + ); + $page = $this->get( route( 'wporghelpscoutimport.index' ) )->getContent(); + + $this->assertStringContainsString( '3 sent, done', $page ); + $this->assertStringContainsString( 'WordPress.org answered 503.', $page ); + } + /** * A run that hasn't saved progress for a while is shown stalled, and can be resumed. * diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php index 2394eb36a0..51064dad44 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/tests/ImporterTest.php @@ -21,6 +21,7 @@ use Modules\WPOrgHelpScoutImport\Entities\ImportedConversation; use Modules\WPOrgHelpScoutImport\Entities\ImportedThread; use Modules\WPOrgHelpScoutImport\Entities\Person; +use Modules\WPOrgHelpScoutImport\Providers\WPOrgHelpScoutImportServiceProvider; use Modules\WPOrgHelpScoutImport\Services\HelpScout; use Modules\WPOrgHelpScoutImport\Services\Importer; use Modules\WPOrgHelpScoutImport\Services\People; @@ -394,6 +395,19 @@ public function test_conversation_staying_in_its_mailbox_updates_its_counters(): $this->assertSame( 0, (int) $themes->folders()->where( 'type', Folder::TYPE_UNASSIGNED )->value( 'active_count' ) ); } + /** + * An imported conversation's events name its HelpScout conversation, for WordPress.org's copy. + * + * @return void + */ + public function test_names_the_helpscout_conversation_of_imported_ones(): void { + $this->app->register( WPOrgHelpScoutImportServiceProvider::class ); + $source = $this->conversation(); + $this->importer->import( $source, $this->mailbox ); + + $this->assertSame( (int) $source['id'], \Eventy::filter( 'wporgwebhooks.helpscout_id', 0, $this->imported_conversation() ) ); + } + /** * A HelpScout user without a FreeScout user gets one, which can log in while HelpScout lists them. * diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php index d77c39b45d..747bbf6cfe 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/Jobs/SendEvent.php @@ -14,7 +14,9 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Modules\WPOrgWebhooks\Services\Client; +use Modules\WPOrgWebhooks\Services\EventPayload; use Modules\WPOrgWebhooks\Services\NotDeliveredException; +use Modules\WPOrgWebhooks\Services\PayloadTooLargeException; /** * Posts an event to the WordPress.org webhook endpoint. @@ -59,14 +61,20 @@ public function __construct( array $payload ) { /** * Sends the event, and tries again later if api.wordpress.org didn't get it. * - * Other failures aren't retried, since webhook.php may have counted the event already. They fail the job rather - * than throw: the worker would retry anything thrown until the job runs out of tries. + * If WordPress.org asks for all of the conversation's threads, it's sent again with them. Other failures aren't + * retried, since webhook.php may have counted the event already. They fail the job rather than throw: the worker + * would retry anything thrown until the job runs out of tries. * * @return void */ public function handle(): void { try { - Client::from_config()->post( (string) config( 'wporgwebhooks.endpoint' ), $this->payload ); + $client = Client::from_config(); + $response = self::send( $client, self::current( $this->payload ) ); + + if ( 'all' === ( $response['threads'] ?? '' ) && empty( $this->payload['all_threads'] ) ) { + self::send( $client, self::current( array( 'all_threads' => true ) + $this->payload ) ); + } } catch ( NotDeliveredException $e ) { if ( $this->attempts() < $this->tries ) { $this->release( self::RETRY_DELAY * $this->attempts() ); @@ -79,4 +87,46 @@ public function handle(): void { $this->fail( $e ); } } + + /** + * Posts an event; one too large for the web server goes again without its threads. + * + * @param Client $client Client. + * @param array $payload Event payload, as it's sent. + * @return array What webhook.php answered. + * + * @throws PayloadTooLargeException If it's too large without its threads too. + */ + private static function send( Client $client, array $payload ): array { + $endpoint = (string) config( 'wporgwebhooks.endpoint' ); + + try { + return $client->post( $endpoint, $payload ); + } catch ( PayloadTooLargeException $e ) { + if ( empty( $payload['email']['threads'] ) ) { + throw $e; + } + + \Log::error( '[WPOrgWebhooks] Sending conversation ' . (int) ( $payload['conversation']['id'] ?? 0 ) . '\'s event without its threads: ' . $e->getMessage() ); + $payload['email']['threads'] = array(); + + return $client->post( $endpoint, $payload ); + } + } + + /** + * The payload with the conversation as it is now; as built, if that can't be read. + * + * @param array $payload Event payload. + * @return array + */ + private static function current( array $payload ): array { + try { + return EventPayload::refresh( $payload ); + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgWebhooks] Could not refresh an event: ' . $e->getMessage() ); + + return $payload; + } + } } diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php index 6225603a6a..69fb07837a 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/Providers/WPOrgWebhooksServiceProvider.php @@ -91,7 +91,7 @@ static function ( $conversation = null, $thread = null ) use ( $event ): void { self::forward( $event, static function () use ( $conversation, $thread ): ?array { - return self::imported( $thread ) || $conversation->isSpam() ? null : array( $conversation, null ); + return self::imported( $thread ) || $conversation->isSpam() ? null : array( $conversation, null, self::thread( $thread ) ); } ); }, @@ -106,7 +106,7 @@ static function ( $conversation = null, $thread = null ): void { self::forward( 'conversation.created_by_user', static function () use ( $conversation, $thread ): ?array { - return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user ) : null; + return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user, self::thread( $thread ) ) : null; } ); }, @@ -120,7 +120,7 @@ static function ( $conversation = null, $thread = null ): void { self::forward( 'conversation.user_replied', static function () use ( $conversation, $thread ): ?array { - return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user ) : null; + return ! self::imported( $thread ) && self::sent( $thread ) ? array( $conversation, $thread->created_by_user, self::thread( $thread ) ) : null; } ); }, @@ -128,7 +128,39 @@ static function () use ( $conversation, $thread ): ?array { 2 ); - foreach ( array( 'conversation.user_changed', 'conversation.status_changed', 'conversation.moved' ) as $event ) { + // Leaving spam, the conversation is copied in full again. + \Eventy::addAction( + 'conversation.status_changed', + static function ( $conversation = null, $user = null, $changed_on_reply = false, $prev_status = null ): void { + self::forward( + 'conversation.status_changed', + static function () use ( $conversation, $user, $prev_status ): array { + $unspammed = Conversation::STATUS_SPAM === (int) $prev_status && $conversation instanceof Conversation && ! $conversation->isSpam(); + + return array( $conversation, $user, $unspammed ? array( 'unspammed' => true ) : array() ); + } + ); + }, + 20, + 4 + ); + + // For the plugins and themes it mentions; not credited. + \Eventy::addAction( + 'conversation.note_added', + static function ( $conversation = null, $thread = null ): void { + self::forward( + 'conversation.note_added', + static function () use ( $conversation, $thread ): ?array { + return self::imported( $thread ) ? null : array( $conversation, $thread->created_by_user ?? null, self::thread( $thread ) ); + } + ); + }, + 20, + 2 + ); + + foreach ( array( 'conversation.user_changed', 'conversation.moved' ) as $event ) { \Eventy::addAction( $event, static function ( $conversation = null, $user = null ) use ( $event ): void { @@ -149,8 +181,26 @@ static function () use ( $conversation, $user ): array { static function ( $conversation = null, $second = null, $user = null ): void { self::forward( 'conversation.merged', - static function () use ( $conversation, $user ): array { - return array( $conversation, $user ); + static function () use ( $conversation, $second, $user ): array { + // WordPress.org folds the merged conversation's copy into this one. + return array( $conversation, $user, array( 'merged_id' => $second instanceof Conversation ? (int) $second->id : 0 ) ); + } + ); + }, + 20, + 3 + ); + + // The copy dropped the conversation when it was deleted. + \Eventy::addAction( + 'conversation.state_changed', + static function ( $conversation = null, $user = null, $previous_state = null ): void { + self::forward( + 'conversation.restored', + static function () use ( $conversation, $user, $previous_state ): ?array { + $restored = Conversation::STATE_DELETED === (int) $previous_state && Conversation::STATE_PUBLISHED === (int) $conversation->state; + + return $restored ? array( $conversation, $user ) : null; } ); }, @@ -197,6 +247,16 @@ private static function is_robot( User $user ): bool { return defined( User::class . '::TYPE_ROBOT' ) && User::TYPE_ROBOT === (int) $user->type; } + /** + * The reply an event is about, so WordPress.org reads the threads up to it, however late the event is sent. + * + * @param Thread|null $thread Reply. + * @return array Empty without one. + */ + private static function thread( ?Thread $thread ): array { + return $thread ? array( 'thread_id' => (int) $thread->id ) : array(); + } + /** * Whether a reply was sent and hasn't been counted yet. * @@ -235,7 +295,8 @@ private static function merged_away( Conversation $conversation ): bool { * Never throws: a failure here must not interrupt the core action that fired the event. * * @param string $event Event name. - * @param callable $resolve Returns the conversation and the agent who caused the event (or null), or null to skip it. + * @param callable $resolve Returns the conversation, the agent who caused the event (or null), and optionally event + * details; or null to skip it. * @return void */ private static function forward( string $event, callable $resolve ): void { @@ -250,13 +311,14 @@ private static function forward( string $event, callable $resolve ): void { } list( $conversation, $agent ) = $args; + $extra = (array) ( $args[2] ?? array() ); // Automations have no WordPress.org account to credit. if ( ! $agent instanceof User || self::is_robot( $agent ) ) { $agent = null; } - SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent ) ); + SendEvent::dispatch( EventPayload::build( $event, $conversation, $agent, $extra ) ); } catch ( \Throwable $e ) { \Log::error( '[WPOrgWebhooks] Could not queue ' . $event . ': ' . $e->getMessage() ); } diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md b/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md index a433fbfd96..f70a094bcd 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/README.md @@ -1,6 +1,6 @@ # WP.org Webhooks -Sends conversation events to WordPress.org, which records them as contributor stats for the agents who handled them. +Sends conversation events to WordPress.org, which records them as contributor stats for the agents who handled them, and keeps a copy of the conversations that the plugin directory reads. ## What it sends @@ -9,16 +9,25 @@ An event, the conversation, its mailbox, and the agent with the WordPress.org ac - a conversation comes in, or an agent starts one; - the sender or an agent replies (an undone reply isn't counted); - a conversation is assigned, changes status, or moves to another mailbox; -- conversations are merged, or one is moved to Deleted (a conversation merged into another counts as the merge). +- conversations are merged, or one is moved to Deleted (a conversation merged into another counts as the merge), or restored from it. +- an agent adds a note, which WordPress.org doesn't credit to them, but reads for the plugins and themes it mentions. + +Events of conversations `WPOrgHelpScoutImport` imported count for stats, but don't carry the conversation for WordPress.org's copy until WordPress.org is pointed at their mailbox (the `wporgwebhooks.copy` filter); see its README. Not sent: -- imported conversations, which the service they came from counted already (what agents do with them in FreeScout is sent); +- imported threads as they're written, which the service they came from counted already (what agents do with them in FreeScout is sent); - spam from senders (agents marking spam is sent). Automations, like Workflows, have no WordPress.org account, so their events are sent without an agent. -Nothing from the emails themselves is sent. Events go out from FreeScout's queue, and are retried if WordPress.org can't be reached. +Each event also carries the conversation as WordPress.org keeps it, read when the event is sent, so a late or retried one says what's current: its mailbox, state, number, subject, status, preview, and dates, and the sender. The event's own mailbox, which stats count, stays the one it happened in. + +For the plugins and themes they mention, a reply also carries the threads from the previous reply up to it (itself, and the notes before it), a note itself, and a conversation new to the copy, restored, taken out of spam, or moved to another mailbox, its newest 50 threads; other events carry none, as the copy keeps what earlier ones mentioned. When WordPress.org has no copy of a conversation yet, it takes nothing from an event without all of its threads, and asks for them: the event goes again at once with them, and only that one counts. Threads go as text, with their links' addresses, up to 256 KB an event, cutting the oldest short; an event api.wordpress.org's web server still refuses for its size goes again without them. + +A merge carries the ID of the conversation merged away, and every event of an imported conversation the HelpScout ID whose copy it replaces, as does a merge for the conversation merged away (from the `wporgwebhooks.helpscout_id` filter, which `WPOrgHelpScoutImport` answers, also for conversations deleted for good since). The copy names the HelpScout conversations it replaced, which keeps HelpScout's webhook from writing them back. Whether an event carries the conversation at all is up to the `wporgwebhooks.copy` filter, which `WPOrgHelpScoutImport` answers for imported conversations. That copy is the `wporg_helpscout` table HelpScout's webhook filled, which the plugin directory lists a plugin's emails from, and finds review conversations in; deleted conversations, and spam, are taken out of it. + +Events go out from FreeScout's queue, and are retried if WordPress.org can't be reached. ## Setup diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php index 8b29274f3b..7f19b24d36 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/Client.php @@ -32,6 +32,13 @@ final class Client { */ private const NOT_DELIVERED_STATUSES = array( 502, 503 ); + /** + * Status the web server refuses an oversized request with, before webhook.php sees it. + * + * @var int + */ + private const PAYLOAD_TOO_LARGE = 413; + /** * Base URL of the endpoints, with a trailing slash. * @@ -53,26 +60,39 @@ final class Client { */ private $timeout; + /** + * Guzzle handler, for tests to answer requests. + * + * @var callable|null + */ + private $handler; + /** * Constructor. * - * @param string $base_url Base URL of the endpoints. - * @param string $secret Shared signing secret. - * @param int $timeout Request timeout in seconds. + * @param string $base_url Base URL of the endpoints. + * @param string $secret Shared signing secret. + * @param int $timeout Request timeout in seconds. + * @param callable|null $handler Guzzle handler; the default sends real requests. */ - public function __construct( string $base_url, string $secret, int $timeout = 10 ) { + public function __construct( string $base_url, string $secret, int $timeout = 10, ?callable $handler = null ) { $this->base_url = rtrim( $base_url, '/' ) . '/'; $this->secret = $secret; $this->timeout = $timeout; + $this->handler = $handler; } /** - * Creates a client from the module configuration. + * Creates a client from the module configuration, or returns the one tests bound. * * @param int $timeout Request timeout in seconds. * @return self */ public static function from_config( int $timeout = 10 ): self { + if ( app()->bound( self::class ) ) { + return app( self::class ); + } + return new self( (string) config( 'wporgwebhooks.api_url' ), (string) config( 'wporgwebhooks.secret' ), @@ -118,19 +138,19 @@ public static function encode( array $payload, string $endpoint ): string { /** * Posts a signed payload to an endpoint. * - * Only a 2xx response counts as delivered, whatever its body. Redirects aren't followed, since they'd turn the - * request into a GET. + * Only a 2xx response counts as delivered. Redirects aren't followed, since they'd turn the request into a GET. * * @param string $endpoint Endpoint path relative to the base URL. * @param array $payload Request payload. - * @return void + * @return array The response's JSON object; empty if it has none. * - * @throws NotDeliveredException If api.wordpress.org certainly didn't get the request: it couldn't be reached, or - * its web server answered for it. - * @throws RuntimeException If the client is not configured, or the request failed in a way that may have - * reached webhook.php, like timing out after it was sent. + * @throws NotDeliveredException If api.wordpress.org certainly didn't get the request: it couldn't be reached, + * or its web server answered for it. + * @throws PayloadTooLargeException If its web server refused the request for its size, without passing it on. + * @throws RuntimeException If the client is not configured, or the request failed in a way that may have + * reached webhook.php, like timing out after it was sent. */ - public function post( string $endpoint, array $payload ): void { + public function post( string $endpoint, array $payload ): array { if ( ! $this->is_configured() ) { throw new RuntimeException( 'WPORG_API_SECRET is not configured.' ); } @@ -139,15 +159,16 @@ public function post( string $endpoint, array $payload ): void { try { // Whatever APP_CURL_SSL_VERIFYPEER says. - $http = new \GuzzleHttp\Client( - \Helper::setGuzzleDefaultOptions( - array( - 'timeout' => $this->timeout, - 'allow_redirects' => false, - 'verify' => true, - ) - ) + $options = array( + 'timeout' => $this->timeout, + 'allow_redirects' => false, + 'verify' => true, ); + if ( $this->handler ) { + $options['handler'] = \GuzzleHttp\HandlerStack::create( $this->handler ); + } + + $http = new \GuzzleHttp\Client( \Helper::setGuzzleDefaultOptions( $options ) ); $response = $http->post( $this->base_url . ltrim( $endpoint, '/' ), array( @@ -172,8 +193,16 @@ public function post( string $endpoint, array $payload ): void { throw new NotDeliveredException( 'Request to ' . $endpoint . ' got a ' . $status . ' response.' ); } + if ( self::PAYLOAD_TOO_LARGE === $status ) { + throw new PayloadTooLargeException( 'Request to ' . $endpoint . ' was too large: ' . strlen( $body ) . ' bytes.' ); + } + if ( $status < 200 || $status > 299 ) { throw new RuntimeException( 'Request to ' . $endpoint . ' got a ' . $status . ' response.' ); } + + $data = json_decode( trim( (string) $response->getBody() ), true ); + + return is_array( $data ) ? $data : array(); } } diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php index d05dd701d5..5d765e2006 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/EventPayload.php @@ -10,36 +10,105 @@ namespace Modules\WPOrgWebhooks\Services; use App\Conversation; +use App\Thread; use App\User; +use Carbon\Carbon; use Modules\WPOrgSSO\Entities\Account; /** * Builds the webhook payload for a conversation event. * - * Read by api.wordpress.org/dotorg/freescout/webhook.php; keep the two in sync. + * Read by api.wordpress.org/dotorg/freescout/webhook.php; keep the two in sync. Carries the event, for contributor + * stats, and the conversation, for WordPress.org's copy of it. */ final class EventPayload { + /** + * Most threads an event carries. + * + * @var int + */ + private const MAX_THREADS = 50; + + /** + * Most bytes of thread text an event carries; past it, the oldest threads are cut short or left out. + * + * @var int + */ + private const MAX_TEXT_BYTES = 256 * 1024; + + /** + * Events that carry all of a conversation's threads: the copy has none of it yet, or it moved to another mailbox, + * which changes what it mentions. See reads_all() for the rest. + * + * @var string[] + */ + private const ALL_THREADS_EVENTS = array( + 'conversation.created_by_customer', + 'conversation.created_by_user', + 'conversation.restored', + 'conversation.moved', + ); + + /** + * Events that carry the threads since the previous reply: the reply, and the notes before it. Events not listed carry + * none. + * + * @var string[] + */ + private const NEW_THREADS_EVENTS = array( + 'conversation.customer_replied', + 'conversation.user_replied', + ); + + /** + * Events that carry only the thread they're about. + * + * @var string[] + */ + private const OWN_THREAD_EVENTS = array( 'conversation.note_added' ); + + /** + * Statuses, as WordPress.org's copy names them. + * + * @var string[] + */ + public const STATUSES = array( + Conversation::STATUS_ACTIVE => 'active', + Conversation::STATUS_PENDING => 'pending', + Conversation::STATUS_CLOSED => 'closed', + Conversation::STATUS_SPAM => 'spam', + ); + + /** + * Thread types, by name. + * + * @var string[] + */ + private const THREAD_TYPES = array( + Thread::TYPE_CUSTOMER => 'customer', + Thread::TYPE_MESSAGE => 'message', + Thread::TYPE_NOTE => 'note', + ); + /** * Builds the payload. * + * The conversation is added when the event is sent; see refresh(). + * * @param string $event Event name: the FreeScout hook that fired. * @param Conversation $conversation Conversation the event happened on. * @param User|null $agent Agent who caused the event, if any. + * @param array $extra Event details, like the ID of a conversation merged into this one. * @return array */ - public static function build( string $event, Conversation $conversation, ?User $agent ): array { - $mailbox = $conversation->mailbox; - - return array( + public static function build( string $event, Conversation $conversation, ?User $agent, array $extra = array() ): array { + return $extra + array( 'event' => $event, 'conversation' => array( 'id' => (int) $conversation->id, ), - 'mailbox' => array( - 'id' => (int) $conversation->mailbox_id, - 'name' => $mailbox ? (string) $mailbox->name : '', - ), + 'mailbox' => self::mailbox( $conversation ), 'agent' => $agent ? array( 'id' => (int) $agent->id, 'email' => (string) $agent->email, @@ -48,6 +117,232 @@ public static function build( string $event, Conversation $conversation, ?User $ ); } + /** + * Adds the conversation as it is now: events can be sent late or out of order, and the copy takes what the latest + * says. The event's own mailbox stays, for stats. + * + * @param array $payload The payload; `all_threads` if WordPress.org asked for them. + * @return array + */ + public static function refresh( array $payload ): array { + $id = (int) ( $payload['conversation']['id'] ?? 0 ); + $conversation = Conversation::find( $id ); + $mailbox_id = $conversation ? (int) $conversation->mailbox_id : (int) ( $payload['mailbox']['id'] ?? 0 ); + + /** + * Filters whether an event carries the conversation for WordPress.org's copy; it counts for stats either way. + * + * @param bool $copy Whether it does. + * @param Conversation|null $conversation Conversation; null if it was deleted for good since the event. + * @param int $conversation_id Its ID. + * @param int $mailbox_id Its mailbox's ID; the event's, if it was deleted for good. + */ + if ( ! \Eventy::filter( 'wporgwebhooks.copy', true, $conversation, $id, $mailbox_id ) ) { + unset( $payload['email'] ); + + return $payload; + } + + $helpscout_id = self::helpscout_id( $conversation, $id ); + if ( $helpscout_id && empty( $payload['helpscout_id'] ) ) { + $payload['helpscout_id'] = $helpscout_id; + } + + // The conversation merged into this one may have a HelpScout copy too. + $merged_id = (int) ( $payload['merged_id'] ?? 0 ); + if ( $merged_id && empty( $payload['merged_helpscout_id'] ) ) { + $merged_helpscout_id = self::helpscout_id( Conversation::find( $merged_id ), $merged_id ); + if ( $merged_helpscout_id ) { + $payload['merged_helpscout_id'] = $merged_helpscout_id; + } + } + + $payload['email'] = $conversation ? self::email( $conversation, $payload ) : array( 'state' => 'deleted' ); + + return $payload; + } + + /** + * The ID of the HelpScout conversation a conversation was imported from, whose copy its own replaces. + * + * @param Conversation|null $conversation Conversation; null if it was deleted for good. + * @param int $conversation_id Its ID. + * @return int 0 if it wasn't imported. + */ + private static function helpscout_id( ?Conversation $conversation, int $conversation_id ): int { + /** + * Filters the ID of the HelpScout conversation a conversation was imported from. + * + * @param int $helpscout_id HelpScout's ID; 0 if it wasn't imported. + * @param Conversation|null $conversation Conversation; null if it was deleted for good. + * @param int $conversation_id Its ID. + */ + return (int) \Eventy::filter( 'wporgwebhooks.helpscout_id', 0, $conversation, $conversation_id ); + } + + /** + * The conversation's mailbox. + * + * @param Conversation $conversation Conversation. + * @return array + */ + private static function mailbox( Conversation $conversation ): array { + $mailbox = $conversation->mailbox; + + return array( + 'id' => (int) $conversation->mailbox_id, + 'name' => $mailbox ? (string) $mailbox->name : '', + ); + } + + /** + * The conversation, as WordPress.org keeps a copy of it. + * + * @param Conversation $conversation Conversation. + * @param array $payload The event's payload. + * @return array + */ + private static function email( Conversation $conversation, array $payload ): array { + $customer = $conversation->customer; + $all = self::reads_all( $payload ); + + return array( + 'mailbox' => self::mailbox( $conversation ), + 'state' => Conversation::STATE_DELETED === (int) $conversation->state ? 'deleted' : 'published', + 'number' => (int) $conversation->number, + 'subject' => (string) $conversation->subject, + 'status' => self::STATUSES[ (int) $conversation->status ] ?? 'active', + 'preview' => (string) $conversation->preview, + 'created_at' => self::date( $conversation->created_at ), + 'closed_at' => self::date( $conversation->closed_at ), + 'user_updated_at' => self::date( $conversation->user_updated_at ), + 'sender' => array( + 'email' => (string) $conversation->customer_email, + 'emails' => $customer ? $customer->emails->pluck( 'email' )->values()->all() : array(), + 'first_name' => $customer ? (string) $customer->first_name : '', + 'last_name' => $customer ? (string) $customer->last_name : '', + ), + 'all_threads' => $all, + 'threads' => self::threads( $conversation, $payload, $all ), + ); + } + + /** + * Whether WordPress.org reads all of a conversation's threads after an event. + * + * @param array $payload The event's payload. + * @return bool + */ + private static function reads_all( array $payload ): bool { + return ! empty( $payload['all_threads'] ) + || ! empty( $payload['unspammed'] ) + || in_array( (string) ( $payload['event'] ?? '' ), self::ALL_THREADS_EVENTS, true ); + } + + /** + * The threads WordPress.org reads after an event, newest first, as text. + * + * @param Conversation $conversation Conversation. + * @param array $payload The event's payload: its name, and the reply it's about. + * @param bool $all Whether WordPress.org reads all of them; see reads_all(). + * @return array[] Type, and body, as text; see text(). + */ + private static function threads( Conversation $conversation, array $payload, bool $all ): array { + $event = (string) ( $payload['event'] ?? '' ); + $thread_id = (int) ( $payload['thread_id'] ?? 0 ); + $own = ! $all && in_array( $event, self::OWN_THREAD_EVENTS, true ); + if ( ! $all && ! $own && ! in_array( $event, self::NEW_THREADS_EVENTS, true ) ) { + return array(); + } + + $query = $conversation->threads() + ->where( 'state', Thread::STATE_PUBLISHED ) + ->whereIn( 'type', array_keys( self::THREAD_TYPES ) ); + + // A reply's threads end at it, even if more came before the event was sent. + if ( $own ) { + $query->where( 'id', $thread_id ); + } elseif ( ! $all && $thread_id ) { + $query->where( 'id', '<=', $thread_id )->orderBy( 'id', 'desc' ); + } else { + $query->orderBy( 'created_at', 'desc' )->orderBy( 'id', 'desc' ); + } + + $sent = array(); + $replies = 0; + $budget = self::MAX_TEXT_BYTES; + foreach ( $query->limit( self::MAX_THREADS )->get( array( 'type', 'body' ) ) as $thread ) { + $is_reply = Thread::TYPE_NOTE !== (int) $thread->type; + if ( ( ! $all && $is_reply && ++$replies > 1 ) || $budget <= 0 ) { + break; + } + + $text = mb_strcut( self::text( (string) $thread->body ), 0, $budget, 'UTF-8' ); + $budget -= strlen( $text ); + $sent[] = array( + 'type' => self::THREAD_TYPES[ (int) $thread->type ], + 'body' => $text, + ); + } + + return $sent; + } + + /** + * A thread's HTML as text, for finding the plugins and themes it mentions: a line per block, and link addresses on + * lines of their own. + * + * Angle brackets are removed, so stripping tags on WordPress.org can't swallow text. + * + * @param string $html The thread's body. + * @return string + */ + public static function text( string $html ): string { + $html = mb_scrub( $html, 'UTF-8' ); + $html = (string) preg_replace( '#<(script|style)\b[^>]*>.*?#is', '', $html ); + + preg_match_all( '#\bhref\s*=\s*(["\'])(.*?)\1#is', $html, $links ); + + $text = (string) preg_replace( '#]*>|#i', "\n", $html ); + $text = self::decode( strip_tags( $text ) ); + + $urls = array(); + foreach ( $links[2] as $url ) { + $url = trim( self::decode( $url ) ); + if ( '' !== $url && ! str_contains( $text, $url ) ) { + $urls[ $url ] = true; + } + } + + $text = (string) preg_replace( '/[ \t\x{00A0}]+/u', ' ', $text . "\n" . implode( "\n", array_keys( $urls ) ) ); + + return trim( (string) preg_replace( '/ *\n\s*/', "\n", $text ) ); + } + + /** + * Decodes HTML entities, and takes out angle brackets; see text(). + * + * @param string $text Text with entities. + * @return string + */ + private static function decode( string $text ): string { + return str_replace( array( '<', '>' ), ' ', html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); + } + + /** + * A stored time, in UTC, ISO 8601. + * + * @param mixed $date Time in the app's timezone, as a Carbon instance or a string; empty for none. + * @return string|null + */ + private static function date( $date ): ?string { + if ( ! $date ) { + return null; + } + + return Carbon::parse( (string) $date, config( 'app.timezone' ) )->setTimezone( 'UTC' )->format( 'Y-m-d\TH:i:s\Z' ); + } + /** * The WordPress.org account WPOrgSSO connected an agent to, which contributor stats credit. * diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/PayloadTooLargeException.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/PayloadTooLargeException.php new file mode 100644 index 0000000000..98bfb900a3 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/Services/PayloadTooLargeException.php @@ -0,0 +1,18 @@ +assert_queued( 'conversation.merged', (string) $this->agent->email ); Queue::assertPushed( SendEvent::class, 1 ); + + $this->assertSame( (int) $second->id, $this->payload()['merged_id'] ); + } + + /** + * Restoring a conversation from Deleted is sent, so WordPress.org copies it again; other state changes aren't. + * + * @return void + */ + public function test_restoring_a_conversation_is_forwarded(): void { + \Eventy::action( 'conversation.state_changed', $this->conversation, $this->agent, Conversation::STATE_DRAFT ); + Queue::assertNotPushed( SendEvent::class ); + + \Eventy::action( 'conversation.state_changed', $this->conversation, $this->agent, Conversation::STATE_DELETED ); + $this->assert_queued( 'conversation.restored', (string) $this->agent->email ); + } + + /** + * Events carry the conversation as WordPress.org keeps a copy of it: its details, and sender; one that doesn't add a + * thread, no threads. + * + * @return void + */ + public function test_events_carry_the_conversation(): void { + $this->conversation->update( + array( + 'subject' => '[WordPress Plugin Directory] Review in Progress: My Plugin', + 'status' => Conversation::STATUS_CLOSED, + ) + ); + $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); + + \Eventy::action( 'conversation.status_changed', $this->conversation->fresh(), $this->agent, false, Conversation::STATUS_ACTIVE ); + + $email = $this->payload()['email']; + $this->assertSame( (int) $this->conversation->number, $email['number'] ); + $this->assertSame( '[WordPress Plugin Directory] Review in Progress: My Plugin', $email['subject'] ); + $this->assertSame( 'closed', $email['status'] ); + $this->assertMatchesRegularExpression( '/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/', (string) $email['created_at'] ); + $this->assertSame( $this->conversation->customer_email, $email['sender']['email'] ); + $this->assertSame( array( 'Jane', 'Sender' ), array( $email['sender']['first_name'], $email['sender']['last_name'] ) ); + $this->assertSame( array(), $email['threads'] ); + } + + /** + * A reply carries the threads since the previous reply, newest first: itself, and the notes before it. + * + * @return void + */ + public function test_replies_carry_the_threads_since_the_previous_reply(): void { + $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); + $reply = $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Thanks', $this->agent, '2026-01-03 10:00:00' ); + + \Eventy::action( 'conversation.user_replied', $this->conversation, $reply ); + + $this->assertSame( + array( + array( + 'type' => 'message', + 'body' => 'Thanks', + ), + array( + 'type' => 'note', + 'body' => 'https://wordpress.org/plugins/my-plugin/', + ), + ), + $this->payload()['email']['threads'] + ); + } + + /** + * A note goes out with itself, for the plugins and themes it mentions. + * + * @return void + */ + public function test_notes_carry_themselves(): void { + $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $note = $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); + + \Eventy::action( 'conversation.note_added', $this->conversation, $note ); + + $this->assert_queued( 'conversation.note_added', (string) $this->agent->email ); + $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/' ), array_column( $this->payload()['email']['threads'], 'body' ) ); + } + + /** + * A reply's threads end with it, even when replies came after it before the event was sent. + * + * @return void + */ + public function test_late_replies_carry_their_own_threads(): void { + $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Hello', $this->agent, '2026-01-01 10:00:00' ); + $reply = $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'https://wordpress.org/plugins/my-plugin/', null, '2026-01-02 10:00:00' ); + + \Eventy::action( 'conversation.customer_replied', $this->conversation, $reply ); + $this->create_thread( $this->conversation, Thread::TYPE_MESSAGE, 'Thanks', $this->agent, '2026-01-03 10:00:00' ); + + $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/' ), array_column( $this->payload()['email']['threads'], 'body' ) ); + } + + /** + * Events the filter keeps out of the copy still go out for stats, without the conversation. + * + * @return void + */ + public function test_events_kept_out_of_the_copy_still_count(): void { + $filter = static function (): bool { + return false; + }; + \Eventy::addFilter( 'wporgwebhooks.copy', $filter ); + + \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); + $payload = $this->payload(); + \Eventy::removeFilter( 'wporgwebhooks.copy', $filter ); + + $this->assertSame( (string) $this->agent->email, $payload['agent']['email'] ); + $this->assertArrayNotHasKey( 'email', $payload ); + } + + /** + * Every event of an imported conversation names the HelpScout conversation whose copy it replaces. + * + * @return void + */ + public function test_events_name_the_helpscout_conversation_they_replace(): void { + $filter = static function (): int { + return 1000000028; + }; + \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter ); + + \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); + $payload = $this->payload(); + \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); + + $this->assertSame( 1000000028, $payload['helpscout_id'] ); + } + + /** + * A late event counts for the mailbox it happened in; the copy takes the one the conversation is in now. + * + * @return void + */ + public function test_late_events_keep_their_mailbox_for_stats(): void { + \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); + $this->conversation->mailbox_id = $this->create_mailbox( 'Themes' )->id; + $this->conversation->save(); + + $payload = $this->payload(); + + $this->assertSame( 'Plugins', $payload['mailbox']['name'] ); + $this->assertSame( 'Themes', $payload['email']['mailbox']['name'] ); + } + + /** + * A conversation deleted for good before its event went out is deleted from the copy, with its HelpScout one, which + * the filter is asked about by ID. + * + * @return void + */ + public function test_events_of_conversations_deleted_since_name_their_helpscout_conversation(): void { + $id = (int) $this->conversation->id; + $filter = static function ( $helpscout_id = 0, $conversation = null, $conversation_id = 0 ) use ( $id ): int { + return null === $conversation && $id === (int) $conversation_id ? 1000000028 : (int) $helpscout_id; + }; + \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter, 10, 3 ); + + \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); + Conversation::deleteConversationsForever( array( $id ) ); + $payload = $this->payload(); + \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); + + $this->assertSame( array( 'state' => 'deleted' ), $payload['email'] ); + $this->assertSame( 1000000028, $payload['helpscout_id'] ); + } + + /** + * A merge names the HelpScout conversation the conversation merged away was imported from, whose copy goes too. + * + * @return void + */ + public function test_merges_name_the_helpscout_conversation_of_the_one_merged_away(): void { + $second = $this->create_conversation( $this->conversation->mailbox, $this->create_sender() ); + $filter = static function ( $helpscout_id = 0, $conversation = null, $conversation_id = 0 ) use ( $second ): int { + return (int) $second->id === (int) $conversation_id ? 1000000029 : (int) $helpscout_id; + }; + \Eventy::addFilter( 'wporgwebhooks.helpscout_id', $filter, 10, 3 ); + + \Eventy::action( 'conversation.merged', $this->conversation, $second, $this->agent ); + $payload = $this->payload(); + \Eventy::removeFilter( 'wporgwebhooks.helpscout_id', $filter ); + + $this->assertSame( array( (int) $second->id, 1000000029 ), array( $payload['merged_id'], $payload['merged_helpscout_id'] ) ); + $this->assertArrayNotHasKey( 'helpscout_id', $payload ); + } + + /** + * Asked for all of the threads, an event carries them, and says so, whatever it is. + * + * @return void + */ + public function test_events_carry_all_threads_when_asked(): void { + $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $this->create_thread( $this->conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->agent, '2026-01-02 10:00:00' ); + + \Eventy::action( 'conversation.status_changed', $this->conversation, $this->agent, false, Conversation::STATUS_ACTIVE ); + $job = Queue::pushed( SendEvent::class )->first(); + + $this->assertFalse( EventPayload::refresh( $job->payload )['email']['all_threads'] ); + + $email = EventPayload::refresh( array( 'all_threads' => true ) + $job->payload )['email']; + $this->assertTrue( $email['all_threads'] ); + $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/', 'Please review' ), array_column( $email['threads'], 'body' ) ); + } + + /** + * Threads go as text: a line per block, and their links' addresses, where WordPress.org looks for plugins and themes. + * + * @return void + */ + public function test_threads_go_as_text(): void { + $this->assertSame( + "Hi Jane,\nSee your plugin and\nPlugin: my-plugin\nUse wp_enqueue script &\nhttps://wordpress.org/plugins/my-plugin/", + EventPayload::text( '

Hi Jane,

See your plugin and
Plugin: my-plugin

Use wp_enqueue <script> &
' ) + ); + } + + /** + * A conversation's threads, sent as it moves, stop at a size WordPress.org takes, cutting the oldest short. + * + * @return void + */ + public function test_threads_stop_at_a_size(): void { + $long = str_repeat( 'x', 100 * 1024 ); + foreach ( array( 1, 2, 3, 4 ) as $day ) { + $this->create_thread( $this->conversation, Thread::TYPE_CUSTOMER, $long, null, '2026-01-0' . $day . ' 10:00:00' ); + } + + \Eventy::action( 'conversation.moved', $this->conversation, $this->agent ); + $threads = $this->payload()['email']['threads']; + + $this->assertCount( 3, $threads ); + $this->assertSame( array( 100 * 1024, 100 * 1024, 56 * 1024 ), array_map( 'strlen', array_column( $threads, 'body' ) ) ); } /** @@ -256,6 +501,19 @@ public function test_nothing_is_queued_without_secret(): void { Queue::assertNotPushed( SendEvent::class ); } + /** + * The payload of the one event queued. + * + * @return array + */ + private function payload(): array { + $jobs = Queue::pushed( SendEvent::class ); + $this->assertCount( 1, $jobs ); + + // As SendEvent sends it. + return EventPayload::refresh( $jobs->first()->payload ); + } + /** * Asserts an event was queued for the conversation. * diff --git a/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php b/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php index bbd17ff879..65b1174e31 100644 --- a/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php +++ b/freescout.wordpress.net/Modules/WPOrgWebhooks/tests/SendEventTest.php @@ -9,9 +9,16 @@ namespace Modules\WPOrgWebhooks\Tests; +use App\Thread; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Response; use Illuminate\Queue\Jobs\DatabaseJob; use Modules\WPOrgWebhooks\Jobs\SendEvent; use Modules\WPOrgWebhooks\Providers\WPOrgWebhooksServiceProvider; +use Modules\WPOrgWebhooks\Services\Client; +use Modules\WPOrgWebhooks\Services\EventPayload; +use Psr\Http\Message\RequestInterface; use WordPressdotorg\FreeScout\Tests\TestCase; /** @@ -70,6 +77,74 @@ public function test_other_failures_are_not_retried(): void { $this->send_event( $queue_job ); } + /** + * WordPress.org asks for all of the threads of a conversation it has no copy of, and gets the event again with them; + * only that one counts. + * + * @return void + */ + public function test_sends_all_threads_when_asked(): void { + $conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $this->create_thread( $conversation, Thread::TYPE_NOTE, 'https://wordpress.org/plugins/my-plugin/', $this->create_user(), '2026-01-02 10:00:00' ); + $sent = $this->answer( new Response( 200, array(), '{"threads":"all"}' ), new Response( 200, array(), '{}' ) ); + + $queue_job = $this->queue_job( 1 ); + $queue_job->expects( $this->never() )->method( 'release' ); + $this->send_event( $queue_job, EventPayload::build( 'conversation.status_changed', $conversation, null ) ); + + $this->assertCount( 2, $sent ); + $this->assertSame( array( false, array() ), array( $sent[0]['email']['all_threads'], $sent[0]['email']['threads'] ) ); + $this->assertTrue( $sent[1]['email']['all_threads'] ); + $this->assertSame( array( 'https://wordpress.org/plugins/my-plugin/', 'Please review' ), array_column( $sent[1]['email']['threads'], 'body' ) ); + } + + /** + * An event too large for api.wordpress.org's web server goes again without its threads. + * + * @return void + */ + public function test_too_large_events_go_without_their_threads(): void { + $conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); + $this->create_thread( $conversation, Thread::TYPE_CUSTOMER, 'Please review', null, '2026-01-01 10:00:00' ); + $sent = $this->answer( new Response( 413 ), new Response( 200, array(), '{}' ) ); + + $queue_job = $this->queue_job( 1 ); + $queue_job->expects( $this->never() )->method( 'markAsFailed' ); + $this->send_event( $queue_job, EventPayload::build( 'conversation.moved', $conversation, null ) ); + + $this->assertCount( 2, $sent ); + $this->assertCount( 1, $sent[0]['email']['threads'] ); + $this->assertSame( array(), $sent[1]['email']['threads'] ); + $this->assertSame( 'conversation.moved', $sent[1]['event'] ); + } + + /** + * Answers api.wordpress.org's requests in turn. + * + * @param Response ...$responses What it answers. + * @return \ArrayObject The payloads it was sent, as they arrive. + */ + private function answer( Response ...$responses ): \ArrayObject { + $sent = new \ArrayObject(); + $handler = new MockHandler( $responses ); + $this->app->instance( + Client::class, + new Client( + 'https://api.wordpress.test/', + 'test-secret', + 5, + static function ( RequestInterface $request, array $options ) use ( $sent, $handler ): PromiseInterface { + $sent[] = json_decode( (string) $request->getBody(), true ); + + return $handler( $request, $options ); + } + ) + ); + + return $sent; + } + /** * Mocks the queued job an event runs as; FreeScout queues in the database. * @@ -87,10 +162,11 @@ private function queue_job( int $attempts ): DatabaseJob { * Runs an event job as the queue worker would. * * @param DatabaseJob $queue_job Queued job the event runs as. + * @param array $payload Event payload. * @return void */ - private function send_event( DatabaseJob $queue_job ): void { - $event = new SendEvent( array( 'event' => 'conversation.user_replied' ) ); + private function send_event( DatabaseJob $queue_job, array $payload = array( 'event' => 'conversation.user_replied' ) ): void { + $event = new SendEvent( $payload ); $event->setJob( $queue_job ); $event->handle(); }