diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 388dc9b8a3..85afa79b3e 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -56,11 +56,12 @@ 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: +- **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 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. + 4. For the Plugins mailbox, define `FREESCOUT_PLUGINS_MAILBOX_ID` and `FREESCOUT_API_KEY`, FreeScout's global API key, on WordPress.org, so the plugin directory's links, searches, new conversations, and queue stats go to FreeScout, and upload confirmations are replied there. The plugin directory writes as FreeScout user 81, connected to wordpressdotorg, which needs access to the Plugins mailbox. The queue stats need the Reports module. Set the Plugins mailbox's **Status after replying** to what HelpScout's was, as authors see their review waiting on them only while its conversation is closed. + 5. 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. diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php index 00ddfff2c9..2df6acc0ce 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php @@ -17,6 +17,14 @@ * @package WordPressdotorg\Plugin_Directory\Admin */ class Status_Transitions { + /** + * The namespace a rejected plugin's slug is wrapped in. + * + * The trailing digits are the suffix `wp_update_post()` adds when the wrapped slug + * is itself taken. + */ + const REJECTED_SLUG_REGEX = '/^rejected-(.+)-rejected(-\d+)?$/i'; + /** * Fetch the instance of the Status_Transitions class. */ @@ -364,7 +372,7 @@ public function rejected( $post_id, $post ) { */ public function restore_rejected_plugin( $post ) { $slug = $post->post_name; - $slug = preg_replace( '!^rejected-(.+)-rejected$!i', '$1', $slug ); + $slug = preg_replace( self::REJECTED_SLUG_REGEX, '$1', $slug ); // Change slug back to 'plugin-name'. wp_update_post( array( diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php index 9622b0bbdc..081a959577 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-author-card.php @@ -7,6 +7,7 @@ use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; use WordPressdotorg\Plugin_Directory\Admin\Tools\Related_Submissions; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; /** * The Author Card admin metabox. @@ -125,9 +126,11 @@ public static function display( $post_or_user_id = '' ) { esc_attr__( 'Click to search Pluginrepo P2 for mentions of this author', 'wporg-plugins' ) ), sprintf( - 'HS', - urlencode( esc_attr( $author->user_nicename ) ), - esc_attr__( 'Click to search Help Scout for mentions of this author', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $author->user_nicename ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for mentions of this author', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::name() ) ), ); vprintf( '[ %s | %s ]', array_map( 'wp_kses_post', $author_links ) ); @@ -143,9 +146,11 @@ public static function display( $post_or_user_id = '' ) { HS', - urlencode( $author->user_email ), - esc_attr__( 'Click to search Help Scout for emails sent to/from this email address', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $author->user_email ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for emails sent to/from this email address', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::name() ) ), ); vprintf( '[ %s ]', array_map( 'wp_kses_post', $author_email_links ) ); @@ -527,9 +532,11 @@ protected static function display_plugin_links( $all_plugins, $author_plugins, $ esc_attr__( 'Click to search Plugin Team P2 for mentions of this plugin', 'wporg-plugins' ) ), sprintf( - 'HS', - rawurlencode( esc_attr( $plugin_name ) ), - esc_attr__( 'Click to search Help Scout for mentions of this plugin', 'wporg-plugins' ) + '%s', + esc_url( Helpdesk::search_url( $plugin_name ) ), + /* translators: %s: Helpdesk name, like FreeScout. */ + esc_attr( sprintf( __( 'Click to search %s for mentions of this plugin', 'wporg-plugins' ), Helpdesk::name() ) ), + esc_html( Helpdesk::name() ) ), ] ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php index 7fb435540a..7f4efb43e1 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-review-tools.php @@ -9,6 +9,7 @@ use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; +use WordPressdotorg\Plugin_Directory\Tools\Helpdesk; use WordPressdotorg\Plugin_Directory\Jobs\Plugin_Import; use WordPressdotorg\Plugin_Directory\Jobs\Plugin_i18n_Import; @@ -420,7 +421,7 @@ public static function display() { }, $committers ); $cc_emails = wp_list_pluck( $committers, 'user_email' ); - $cc_emails = implode( ',', array_diff( $cc_emails, array( $author->user_email ) ) ); + $cc_emails = array_values( array_diff( $cc_emails, array( $author->user_email ) ) ); if ( 'new' === $post->post_status || 'pending' === $post->post_status ) { /* translators: %s: Plugin Title */ @@ -448,8 +449,7 @@ public static function display() { $subject = sprintf( __( '[WordPress Plugin Directory] Notice: %s', 'wporg-plugins' ), $post->post_title ); } - // HelpScout requires urlencode() because it wants spaces as + signs. - $contact_author = 'https://secure.helpscout.net/mailbox/ad3e85554c5bd064/new-ticket/?name=' . $author->display_name . '&email=' . urlencode( $author->user_email ) . '&cc=' . urlencode( $cc_emails ) . '&subject=' . urlencode( $subject ); + $contact_author = Helpdesk::new_conversation_url( $author, $cc_emails, $subject ); ?> Contact plugin committer(s) posts WHERE `post_type` = 'plugin' AND `post_status` = 'pending'" ); - // # Break down the plugins in the queue, based on if we're still waiting on a reply. - // Requires the helpscout tables to exist. - if ( defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { + // Break down the plugins in the queue by whom we're waiting on; needs the helpscout tables, which either helpdesk fills. + if ( Helpdesk::is_freescout() || defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { $stats['in_queue_pending_why'] = $wpdb->get_row( $wpdb->prepare( 'SELECT SUM( IF( active = 0 AND closed > 0, 1, 0 ) ) AS `author`, @@ -199,10 +200,12 @@ public function get_stats( $args = array() ) { ) ); // -------------- - // Help Scout Queue + // Email Queue // -------------- - if ( defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { + if ( Helpdesk::is_freescout() ) { + $stats = array_merge( $stats, $this->get_freescout_queue_stats( $args ) ); + } elseif ( defined( 'HELPSCOUT_PLUGINS_MAILBOXID' ) ) { $start_datetime = gmdate( 'Y-m-d\T00:00:00\Z', strtotime( $args['date'] ) - ( $args['num_days'] * DAY_IN_SECONDS ) ); $end_datetime = gmdate( 'Y-m-d\T23:59:59\Z', strtotime( $args['date'] ) ); @@ -221,19 +224,60 @@ public function get_stats( $args = array() ) { $company_report = $mailbox_overall = $email_report = false; } - $stats['helpscout_queue_total_conversations'] = $mailbox_overall->current->totalConversations ?? 0; - $stats['helpscout_queue_new_conversations'] = $mailbox_overall->current->newConversations ?? 0; - $stats['helpscout_queue_customers'] = $mailbox_overall->current->customers ?? 0; - $stats['helpscout_queue_conversations_per_day'] = $mailbox_overall->current->conversationsPerDay ?? 0; - $stats['helpscout_queue_busiest_day'] = gmdate( 'l', strtotime( 'Sunday +' . ( $mailbox_overall->busiestDay->day ?? 0 ) . ' days' ) ); // Hacky? but works - $stats['helpscout_queue_messages_received'] = $mailbox_overall->current->messagesReceived ?? 0; - $stats['helpscout_queue_replies_sent'] = $company_report->current->totalReplies; - $stats['helpscout_queue_emails_created'] = $email_report->current->volume->emailsCreated ?? 0; + // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Help Scout's API. + $stats['email_queue_total_conversations'] = $mailbox_overall->current->totalConversations ?? 0; + $stats['email_queue_new_conversations'] = $mailbox_overall->current->newConversations ?? 0; + $stats['email_queue_customers'] = $mailbox_overall->current->customers ?? 0; + $stats['email_queue_conversations_per_day'] = $mailbox_overall->current->conversationsPerDay ?? 0; + $stats['email_queue_busiest_day'] = gmdate( 'l', strtotime( 'Sunday +' . ( $mailbox_overall->busiestDay->day ?? 0 ) . ' days' ) ); // Hacky? but works. + $stats['email_queue_messages_received'] = $mailbox_overall->current->messagesReceived ?? 0; + $stats['email_queue_replies_sent'] = $company_report->current->totalReplies; + $stats['email_queue_emails_created'] = $email_report->current->volume->emailsCreated ?? 0; + // phpcs:enable } return $stats; } + /** + * Gets the Plugins mailbox's queue stats from FreeScout's Reports module, through its API. + * + * FreeScout has no count of outgoing conversations, so there's no `email_queue_emails_created`. + * + * @param array $args The stats' date, and number of days before it. + * @return array The queue stats, see get_stats(); zeros if FreeScout's reports couldn't be fetched. + */ + protected function get_freescout_queue_stats( $args ) { + $query = array( + 'filters' => array( + 'from' => gmdate( 'Y-m-d', strtotime( $args['date'] ) - ( $args['num_days'] * DAY_IN_SECONDS ) ), + 'to' => gmdate( 'Y-m-d', strtotime( $args['date'] ) ), + 'mailbox' => Helpdesk::freescout_mailbox_id(), + ), + ); + + $conversations = FreeScout::api( 'reports/conversations', $query ); + $productivity = FreeScout::api( 'reports/productivity', $query ); + + // All zeros, so a failed request is obvious. + if ( ! isset( $conversations['metrics'], $productivity['metrics'] ) ) { + $conversations = array(); + $productivity = array(); + } + + $metrics = $conversations['metrics'] ?? array(); + + return array( + 'email_queue_total_conversations' => (int) ( $metrics['total']['value'] ?? 0 ), + 'email_queue_new_conversations' => (int) ( $metrics['new']['value'] ?? 0 ), + 'email_queue_customers' => (int) ( $metrics['customers']['value'] ?? 0 ), + 'email_queue_conversations_per_day' => (int) ( $metrics['conv_day']['value'] ?? 0 ), + 'email_queue_busiest_day' => (string) ( $metrics['busy_day']['value'] ?? '' ), + 'email_queue_messages_received' => (int) ( $metrics['messages']['value'] ?? 0 ), + 'email_queue_replies_sent' => (int) ( $productivity['metrics']['replies']['value'] ?? 0 ), + ); + } + public function get_user_stats( $args = array() ) { global $wpdb; @@ -581,15 +625,23 @@ public function show_stats() { - -

+ +

+ +

@@ -761,9 +815,9 @@ public function show_stats() { ?> diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php new file mode 100644 index 0000000000..f3dc668eeb --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/clients/class-freescout.php @@ -0,0 +1,97 @@ + $method, + 'timeout' => self::TIMEOUT, + 'headers' => array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'X-FreeScout-API-Key' => $api_key, + ), + 'body' => $body, + ) + ); + + if ( is_wp_error( $response ) ) { + return array( 'error' => $response->get_error_message() ); + } + + $response_code = (int) wp_remote_retrieve_response_code( $response ); + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + return is_array( $data ) ? $data : null; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php index c4f89fe160..fa947907a4 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-upload-handler.php @@ -11,8 +11,10 @@ use WordPressdotorg\Plugin_Directory\Tools\Filesystem; use WordPressdotorg\Plugin_Directory\Tools\Helpscout; use WordPressdotorg\Plugin_Directory\Trademarks; +use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; use WordPressdotorg\Plugin_Directory\Admin\Tools\Upload_Token; use WordPressdotorg\Plugin_Directory\Clients\Helpscout as Helpscout_Client; +use WordPressdotorg\Plugin_Directory\Clients\FreeScout as FreeScout_Client; use WordPressdotorg\Plugin_Directory\Email\Plugin_Submission as Plugin_Submission_Email; /** @@ -652,8 +654,6 @@ public function process_upload( $for_plugin = 0 ) { do_action( 'plugin_upload', $this->plugin, $plugin_post ); if ( $updating_existing ) { - - // Update HelpScout, if in review. $this->update_review_email( $plugin_post, $attachment ); $message = sprintf( @@ -761,7 +761,7 @@ public function has_reserved_slug() { ); // Slugs in the namespace used internally for rejected plugins. - if ( preg_match( Helpscout::REJECTED_SLUG_REGEX, $this->plugin_slug ) ) { + if ( preg_match( Status_Transitions::REJECTED_SLUG_REGEX, $this->plugin_slug ) ) { return true; } @@ -999,7 +999,7 @@ public static function find_review_email( $post ) { } /** - * Update the HelpScout review email. + * Update the review email, in its helpdesk. * * @param WP_Post $post The plugin post. * @param WP_Post $attachment The uploaded attachment post. @@ -1032,6 +1032,10 @@ public static function update_review_email( $post, $attachment ) { // Append the ZIP URL. $text .= "\n" . wp_get_attachment_url( $attachment->ID ); + if ( $review_email->freescout ) { + return self::reply_in_freescout( (int) $review_email->id, $text, wp_get_current_user() ); + } + $name = wp_get_current_user()->display_name ?: wp_get_current_user()->user_login; $payload = [ 'customer' => array_filter( [ @@ -1059,4 +1063,60 @@ public static function update_review_email( $post, $attachment ) { return $success; } + + /** + * Replies to the author in the review conversation in FreeScout, as wordpressdotorg, and makes it active again, so the + * plugins team sees there's something new to review. + * + * @param int $conversation_id The review conversation's ID in FreeScout. + * @param string $text The reply, as text. + * @param \WP_User $author The author who uploaded the update. + * @return bool Whether it was sent. + */ + protected static function reply_in_freescout( int $conversation_id, string $text, \WP_User $author ): bool { + if ( '' === FreeScout_Client::api_key() ) { + trigger_error( 'FreeScout update failed: FREESCOUT_API_KEY isn\'t configured.', E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update. + + return false; + } + + // A new thread replaces the conversation's CC with its own, so pass the current CC on. + $conversation = FreeScout_Client::api( 'conversations/' . $conversation_id, array(), 'GET', $response_code ); + if ( 200 !== $response_code || ! isset( $conversation['cc'] ) ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update; not rendered. + trigger_error( "FreeScout update failed: couldn't read the conversation's CC: $response_code: " . var_export( $conversation, true ), E_USER_WARNING ); + + return false; + } + + // Review conversations are agent-started; a sender's could name any plugin and CC anyone. + if ( 'user' !== ( $conversation['createdBy']['type'] ?? '' ) ) { + trigger_error( "FreeScout update skipped: conversation $conversation_id wasn't started by the plugins team.", E_USER_WARNING ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions, WordPress.Security.EscapeOutput.OutputNotEscaped -- Logged, not shown. + + return false; + } + + $result = FreeScout_Client::api( + 'conversations/' . $conversation_id . '/threads', + array( + 'type' => 'message', + 'text' => nl2br( make_clickable( esc_html( $text ) ) ), + 'user' => FreeScout_Client::USER_ID, + 'to' => array( $author->user_email ), + 'cc' => array_values( (array) $conversation['cc'] ), + 'status' => 'active', + ), + 'POST', + $response_code + ); + + if ( 201 !== $response_code ) { + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped, WordPress.PHP.DevelopmentFunctions -- Logs the failure, like the HelpScout update; not rendered. + trigger_error( "FreeScout update failed: $response_code: " . var_export( $result, true ), E_USER_WARNING ); + + return false; + } + + return true; + } } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php new file mode 100644 index 0000000000..fa5dcd604e --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Helpdesk_Links_Test.php @@ -0,0 +1,97 @@ +mailbox_filter ) { + remove_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + $this->mailbox_filter = null; + } + + parent::tearDown(); + } + + /** + * Without FreeScout's mailbox, links go to Help Scout, as they did. + * + * @return void + */ + public function test_links_to_help_scout_until_freescout_is_configured(): void { + $this->mailbox_filter = static function (): int { + return 0; + }; + add_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + + $this->assertFalse( Helpdesk::is_freescout() ); + $this->assertSame( 'Help Scout', Helpdesk::name() ); + $this->assertSame( 'https://secure.helpscout.net/search/?query=mailbox:Plugins%20My%20Plugin', Helpdesk::search_url( 'My Plugin' ) ); + $this->assertSame( 'https://secure.helpscout.net/conversation/1000000028/12345', Helpdesk::conversation_url( 1000000028, 12345, false ) ); + $this->assertSame( + 'https://secure.helpscout.net/mailbox/ad3e85554c5bd064/new-ticket/?name=Jane+Author&email=jane%40example.org&cc=bob%40example.org%2Csam%40example.org&subject=Review+in+Progress%3A+My+Plugin', + Helpdesk::new_conversation_url( $this->author(), array( 'bob@example.org', 'sam@example.org' ), 'Review in Progress: My Plugin' ) + ); + } + + /** + * With FreeScout's mailbox, links search it, and open a new conversation in it with every committer. + * + * @return void + */ + public function test_links_to_freescout_once_configured(): void { + $this->mailbox_filter = static function (): int { + return 3; + }; + add_filter( 'wporg_plugins_freescout_mailbox_id', $this->mailbox_filter ); + + $this->assertTrue( Helpdesk::is_freescout() ); + $this->assertSame( 'FreeScout', Helpdesk::name() ); + $this->assertSame( 'https://freescout.wordpress.net/search?q=jane%40example.org&f%5Bmailbox%5D=3', Helpdesk::search_url( 'jane@example.org' ) ); + $this->assertSame( 'https://freescout.wordpress.net/conversation/42', Helpdesk::conversation_url( 42, 12345, true ) ); + $this->assertSame( + 'https://freescout.wordpress.net/mailbox/3/new-ticket?to=jane%40example.org%2Cbob%40example.org&subject=Review+in+Progress%3A+My+Plugin', + Helpdesk::new_conversation_url( $this->author(), array( 'bob@example.org' ), 'Review in Progress: My Plugin' ) + ); + } + + /** + * A plugin author. + * + * @return WP_User + */ + protected function author(): WP_User { + $author = new WP_User(); + $author->display_name = 'Jane Author'; + $author->user_email = 'jane@example.org'; + + return $author; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php new file mode 100644 index 0000000000..65692347ce --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Stats_Report_FreeScout_Test.php @@ -0,0 +1,210 @@ +filter( 'wporg_plugins_freescout_mailbox_id', static fn(): int => 3 ); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); + $this->filter( + 'pre_http_request', + function ( mixed $preempt, array $args, string $url ): mixed { + if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { + return $preempt; + } + $this->requests[] = $url; + + $reports = array( + 'conversations' => array( + 'total' => array( 'value' => 128 ), + 'new' => array( 'value' => 96 ), + 'messages' => array( 'value' => 210 ), + 'customers' => array( 'value' => 84 ), + 'conv_day' => array( 'value' => 4 ), + 'busy_day' => array( 'value' => 'Monday' ), + ), + 'productivity' => array( + 'replies' => array( 'value' => 57 ), + 'closed' => array( 'value' => 40 ), + 'customers_helped' => array( 'value' => 33 ), + ), + ); + $report = (string) preg_replace( '#^.*/api/reports/([a-z]+).*$#', '$1', $url ); + + return array( + 'response' => array( 'code' => 200 ), + 'body' => wp_json_encode( + array( + 'report' => $report, + 'metrics' => $reports[ $report ] ?? array(), + ) + ), + ); + } + ); + } + + /** + * Removes what the test added. + * + * @return void + */ + protected function tearDown(): void { + global $wpdb; + + foreach ( $this->filters as list( $hook, $callback ) ) { + remove_filter( $hook, $callback ); + } + foreach ( $this->posts as $post_id ) { + wp_delete_post( $post_id, true ); + } + $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => self::CONVERSATION_ID ) ); + $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => self::CONVERSATION_ID ) ); + + parent::tearDown(); + } + + /** + * The queue stats are the Plugins mailbox's, for the report's dates, from FreeScout's conversations and productivity + * reports. + * + * @return void + */ + public function test_queue_stats_come_from_freescouts_reports(): void { + $stats = Stats_Report::instance()->get_stats( + array( + 'date' => '2026-10-13', + 'num_days' => 7, + ) + ); + + $this->assertSame( + array( + 'email_queue_total_conversations' => 128, + 'email_queue_new_conversations' => 96, + 'email_queue_customers' => 84, + 'email_queue_conversations_per_day' => 4, + 'email_queue_busiest_day' => 'Monday', + 'email_queue_messages_received' => 210, + 'email_queue_replies_sent' => 57, + ), + array_intersect_key( $stats, array_flip( preg_grep( '/^email_queue_/', array_keys( $stats ) ) ) ) + ); + + $query = 'filters%5Bfrom%5D=2026-10-06&filters%5Bto%5D=2026-10-13&filters%5Bmailbox%5D=3'; + $this->assertSame( + array( + 'https://freescout.wordpress.net/api/reports/conversations?' . $query, + 'https://freescout.wordpress.net/api/reports/productivity?' . $query, + ), + $this->requests + ); + } + + /** + * Pending plugins are broken down by who the review waits on, from WordPress.org's copy, without Help Scout. + * + * @return void + */ + public function test_breaks_down_pending_plugins_without_helpscout(): void { + global $wpdb; + + $before = Stats_Report::instance()->get_stats()['in_queue_pending_why'] ?? null; + $this->assertIsArray( $before ); + + $this->posts[] = wp_insert_post( + array( + 'post_type' => 'plugin', + 'post_name' => 'stats-report-fixture', + 'post_title' => 'Stats Report Fixture', + 'post_status' => 'pending', + // Plugin_Directory::filter_wp_insert_post_data() reads it, and fails the insert without it. + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::CONVERSATION_ID, + 'status' => 'closed', + 'subject' => '[WordPress Plugin Directory] Review in Progress: Stats Report Fixture', + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::CONVERSATION_ID, + 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'stats-report-fixture', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + $after = Stats_Report::instance()->get_stats()['in_queue_pending_why']; + + $this->assertSame( $before['author'] + 1, $after['author'] ); + } + + /** + * Adds a filter for the test. + * + * @param string $hook Hook. + * @param Closure $callback Callback. + * @return void + */ + protected function filter( string $hook, Closure $callback ): void { + $this->filters[] = array( $hook, $callback ); + add_filter( $hook, $callback, 10, 3 ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php new file mode 100644 index 0000000000..ca1bfac0e4 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Upload_Confirmation_FreeScout_Test.php @@ -0,0 +1,365 @@ + array(), + 'users' => array(), + ); + + /** + * Configures FreeScout, and answers its API. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + + $this->filter( 'wporg_plugins_freescout_mailbox_id', static fn(): int => 3 ); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => 'test-key' ); + } + + /** + * Removes what the test added. + * + * @return void + */ + protected function tearDown(): void { + global $wpdb; + + foreach ( $this->filters as list( $hook, $callback ) ) { + remove_filter( $hook, $callback ); + } + foreach ( $this->created['posts'] as $post_id ) { + wp_delete_post( $post_id, true ); + } + foreach ( $this->created['users'] as $user_id ) { + wp_delete_user( $user_id ); + } + foreach ( array( self::CONVERSATION_ID, self::HELPSCOUT_ID ) as $id ) { + $wpdb->delete( "{$wpdb->base_prefix}helpscout", array( 'id' => $id ) ); + $wpdb->delete( "{$wpdb->base_prefix}helpscout_meta", array( 'helpscout_id' => $id ) ); + } + wp_set_current_user( 0 ); + + parent::tearDown(); + } + + /** + * The author's upload is confirmed by a reply from the configured agent, which makes the conversation active again, + * and keeps who it copies: a thread sets the conversation's CC to its own. + * + * @return void + */ + public function test_replies_in_the_review_conversation(): void { + $this->answer( array( 'committer@example.org' ) ); + list( $plugin, $attachment, $author ) = $this->upload( true ); + + $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); + + $this->assertCount( 2, $this->requests ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[1]['url'] ); + $this->assertSame( 'test-key', $this->requests[1]['args']['headers']['X-FreeScout-API-Key'] ); + + $body = json_decode( (string) $this->requests[1]['args']['body'], true ); + $this->assertSame( array( 'message', 81, array( $author->user_email ), array( 'committer@example.org' ), 'active' ), array( $body['type'], $body['user'], $body['to'], $body['cc'], $body['status'] ) ); + $this->assertStringContainsString( 'This is an automated message to confirm that we have received your updated plugin file.', $body['text'] ); + $this->assertStringContainsString( 'version 1.2', $body['text'] ); + } + + /** + * Without the conversation's CC, a reply would clear it, so there's none. + * + * @return void + */ + public function test_does_not_reply_without_the_conversations_cc(): void { + $this->answer( null ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); + } + + /** + * No confirmation goes into a conversation a sender started, which can name any plugin and CC anyone. + * + * @return void + */ + public function test_does_not_reply_in_a_conversation_a_sender_started(): void { + $this->answer( array( 'outsider@example.net' ), 'customer' ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array( 'GET' ), wp_list_pluck( wp_list_pluck( $this->requests, 'args' ), 'method' ) ); + } + + + /** + * Without a key, there's nothing to ask FreeScout with. + * + * @return void + */ + public function test_needs_a_key(): void { + $this->answer(); + $this->filter( 'wporg_plugins_freescout_api_key', static fn(): string => '' ); + list( $plugin, $attachment ) = $this->upload( true ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- The warning it logs is expected. + $this->assertFalse( @Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( array(), $this->requests ); + } + + /** + * While HelpScout's copy of an imported conversation lingers next to FreeScout's, with the same date, FreeScout's is + * the review conversation, whatever their IDs. + * + * @return void + */ + public function test_prefers_freescouts_copy_of_the_same_conversation(): void { + global $wpdb; + + $this->answer(); + list( $plugin, $attachment ) = $this->upload( true ); + + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::HELPSCOUT_ID, + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: Upload Confirmation Fixture', + 'created' => '2026-08-01 10:00:00', + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::HELPSCOUT_ID, + 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'upload-confirmation-fixture', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + $emails = Helpscout::get_emails( $plugin ); + $this->assertSame( array( self::CONVERSATION_ID, self::HELPSCOUT_ID ), array_map( 'intval', wp_list_pluck( $emails, 'id' ) ) ); + + $this->assertTrue( Upload_Handler::update_review_email( $plugin, $attachment ) ); + $this->assertSame( 'https://freescout.wordpress.net/api/conversations/' . self::CONVERSATION_ID . '/threads', $this->requests[1]['url'] ); + } + + /** + * A review conversation still in HelpScout is replied to there, even once the plugins team is on FreeScout. + * + * @return void + */ + public function test_leaves_helpscouts_conversations_to_helpscout(): void { + $this->answer(); + list( $plugin, $attachment ) = $this->upload( false ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- HelpScout isn't configured here, and the warning it logs is expected. + @Upload_Handler::update_review_email( $plugin, $attachment ); + + $this->assertSame( array(), $this->requests ); + } + + /** + * A request that gets no response returns why, so the failure that's logged can be told apart. + * + * @return void + */ + public function test_request_without_a_response_returns_the_error(): void { + $this->filter( 'pre_http_request', static fn(): WP_Error => new WP_Error( 'http_request_failed', 'Connection timed out' ) ); + + $result = FreeScout_Client::api( 'conversations/' . self::CONVERSATION_ID, array(), 'GET', $response_code ); + + $this->assertSame( array( 'error' => 'Connection timed out' ), $result ); + $this->assertSame( 0, $response_code ); + } + + /** + * Adds a filter for the test. + * + * @param string $hook Hook. + * @param Closure $callback Callback. + * @return void + */ + protected function filter( string $hook, Closure $callback ): void { + $this->filters[] = array( $hook, $callback ); + add_filter( $hook, $callback, 10, 3 ); + } + + /** + * Answers FreeScout's API, in place of the API & Webhooks module: the review conversation, and that a thread was + * created. + * + * @param string[]|null $cc Who the conversation copies; null if FreeScout can't be reached. + * @param string $created_by Who started the conversation: `user`, an agent, or `customer`, a sender. + * @return void + */ + protected function answer( ?array $cc = array(), string $created_by = 'user' ): void { + $this->filter( + 'pre_http_request', + function ( mixed $preempt, array $args, string $url ) use ( $cc, $created_by ): mixed { + if ( ! str_starts_with( $url, 'https://freescout.wordpress.net/api/' ) ) { + return $preempt; + } + $this->requests[] = compact( 'url', 'args' ); + + if ( 'GET' === $args['method'] ) { + if ( null === $cc ) { + return new WP_Error( 'http_request_failed', 'Connection timed out' ); + } + + $conversation = array( + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'status' => 'closed', + 'createdBy' => array( + 'id' => 7, + 'type' => $created_by, + ), + 'cc' => $cc, + 'bcc' => array(), + ); + + return array( 'response' => array( 'code' => 200 ), 'body' => wp_json_encode( $conversation ) ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. + } + + return array( 'response' => array( 'code' => 201 ), 'body' => '{"id":99}' ); // phpcs:ignore WordPress.Arrays.ArrayDeclarationSpacing.AssociativeArrayFound -- A short HTTP response. + } + ); + } + + /** + * An author's upload to their plugin in review, whose review conversation is in WordPress.org's copy. + * + * @param bool $freescout Whether the conversation is FreeScout's. + * @return array The plugin, the uploaded ZIP, and the author, who is the current user. + */ + protected function upload( bool $freescout ): array { + global $wpdb; + + $author_id = wp_insert_user( + array( + 'user_login' => 'upload-author-' . wp_generate_password( 6, false ), + 'user_email' => 'author-' . wp_generate_password( 6, false ) . '@example.org', + 'user_pass' => wp_generate_password(), + ) + ); + $this->created['users'][] = $author_id; + + $plugin_id = wp_insert_post( + array( + 'post_type' => 'plugin', + 'post_name' => 'upload-confirmation-fixture', + 'post_title' => 'Upload Confirmation Fixture', + 'post_status' => 'pending', + 'post_author' => $author_id, + // Plugin_Directory::filter_wp_insert_post_data() reads it, and fails the insert without it. + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ) + ); + $attachment_id = wp_insert_post( + array( + 'post_type' => 'attachment', + 'post_parent' => $plugin_id, + 'post_status' => 'inherit', + 'post_title' => 'upload-confirmation-fixture.zip', + 'post_content' => '', + 'post_modified' => current_time( 'mysql' ), + 'post_modified_gmt' => current_time( 'mysql', 1 ), + ) + ); + $this->created['posts'][] = $attachment_id; + $this->created['posts'][] = $plugin_id; + update_post_meta( $attachment_id, 'version', '1.2' ); + + $wpdb->insert( + "{$wpdb->base_prefix}helpscout", + array( + 'id' => self::CONVERSATION_ID, + 'number' => 1234, + 'subject' => '[WordPress Plugin Directory] Review in Progress: Upload Confirmation Fixture', + 'created' => '2026-08-01 10:00:00', + ) + ); + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::CONVERSATION_ID, + 'meta_key' => 'plugins', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'upload-confirmation-fixture', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + + if ( $freescout ) { + $wpdb->insert( + "{$wpdb->base_prefix}helpscout_meta", + array( + 'helpscout_id' => self::CONVERSATION_ID, + 'meta_key' => 'helpdesk', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + 'meta_value' => 'freescout', // phpcs:ignore WordPress.DB.SlowDBQuery -- Test fixture. + ) + ); + } + + // Only the author's own upload is confirmed. + wp_set_current_user( $author_id ); + + return array( get_post( $plugin_id ), get_post( $attachment_id ), get_userdata( $author_id ) ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php new file mode 100644 index 0000000000..f05b083714 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpdesk.php @@ -0,0 +1,149 @@ + 0; + } + + /** + * The helpdesk's name. + * + * @return string + */ + public static function name(): string { + return self::is_freescout() ? 'FreeScout' : 'Help Scout'; + } + + /** + * A search of the Plugins mailbox. + * + * @param string $term What to search for. + * @return string URL. + */ + public static function search_url( string $term ): string { + if ( ! self::is_freescout() ) { + return self::HELPSCOUT_SEARCH_URL . '?query=mailbox:Plugins%20' . rawurlencode( $term ); + } + + return self::freescout_url() . 'search?' . http_build_query( + array( + 'q' => $term, + 'f' => array( 'mailbox' => self::freescout_mailbox_id() ), + ) + ); + } + + /** + * The form for a new conversation with a plugin's author, from the Plugins mailbox. + * + * FreeScout's form can't be given a name, or copies, so there the committers are recipients after the author, whom + * FreeScout copies on the conversation. + * + * @param WP_User $author The author. + * @param string[] $cc_emails The other committers' email addresses. + * @param string $subject Subject. + * @return string URL. + */ + public static function new_conversation_url( WP_User $author, array $cc_emails, string $subject ): string { + if ( ! self::is_freescout() ) { + // Help Scout wants spaces as + signs, which http_build_query() writes. + return self::HELPSCOUT_NEW_URL . '?' . http_build_query( + array( + 'name' => $author->display_name, + 'email' => $author->user_email, + 'cc' => implode( ',', $cc_emails ), + 'subject' => $subject, + ) + ); + } + + return self::freescout_url() . 'mailbox/' . self::freescout_mailbox_id() . '/new-ticket?' . http_build_query( + array( + 'to' => implode( ',', array_merge( array( $author->user_email ), $cc_emails ) ), + 'subject' => $subject, + ) + ); + } + + /** + * A conversation from WordPress.org's copy, in the helpdesk it's in. + * + * @param int $id Conversation ID. + * @param int $number Conversation number, which HelpScout's links carry. + * @param bool $freescout Whether it's FreeScout's. + * @return string URL. + */ + public static function conversation_url( int $id, int $number, bool $freescout ): string { + if ( ! $freescout ) { + return 'https://secure.helpscout.net/conversation/' . $id . '/' . $number; + } + + return self::freescout_url() . 'conversation/' . $id; + } + + /** + * FreeScout's address, with a trailing slash. + * + * @return string + */ + public static function freescout_url(): string { + return trailingslashit( defined( 'FREESCOUT_URL' ) && FREESCOUT_URL ? FREESCOUT_URL : self::FREESCOUT_URL ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php index ed69294c3b..a3d364fb8d 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tools/class-helpscout.php @@ -1,18 +1,11 @@ prepare( 'AND emails.%i LIKE %s ', $key, '%' . $value . '%' ); } + /* + * Newest first. An imported conversation shares `created` with its lingering HelpScout copy, so FreeScout's comes + * first, then the higher ID. + */ $emails = $wpdb->get_results( $wpdb->prepare( - "SELECT emails.* + "SELECT emails.*, + EXISTS( SELECT 1 FROM %i helpdesk WHERE helpdesk.helpscout_id = emails.id AND helpdesk.meta_key = 'helpdesk' AND helpdesk.meta_value = 'freescout' ) AS freescout FROM %i emails JOIN %i meta ON emails.id = meta.helpscout_id WHERE meta.meta_key = 'plugins' AND meta.meta_value IN( %s, %s ) $wheres - ORDER BY `created` DESC + ORDER BY emails.`created` DESC, `freescout` DESC, emails.`id` DESC LIMIT %d", + "{$wpdb->base_prefix}helpscout_meta", "{$wpdb->base_prefix}helpscout", "{$wpdb->base_prefix}helpscout_meta", $slug, @@ -77,7 +76,8 @@ public static function get_emails( $post, $filters = [] ) { ) ); foreach ( $emails as &$email ) { - $email->url = 'https://secure.helpscout.net/conversation/' . $email->id . '/' . $email->number; + $email->freescout = (bool) $email->freescout; + $email->url = Helpdesk::conversation_url( (int) $email->id, (int) $email->number, $email->freescout ); } if ( 1 === $limit ) { diff --git a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php index fbd15ef409..91d6bc86e0 100644 --- a/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php +++ b/wordpress.org/public_html/wp-content/plugins/wporg-abilities/plugins/plugin-directory/tools/class-get-plugin-status.php @@ -10,6 +10,7 @@ namespace WordPressdotorg\Abilities\Plugins\Plugin_Directory\Tools; use WordPressdotorg\Abilities\Plugins\Plugin_Directory\Ability_Base; +use WordPressdotorg\Plugin_Directory\Clients\FreeScout as FreeScout_Client; use WordPressdotorg\Plugin_Directory\Clients\HelpScout as HelpScout_Client; use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; @@ -232,14 +233,14 @@ private static function format_meta_timestamp( \WP_Post $post, string $meta_key } /** - * Get review feedback from HelpScout conversation threads. + * Get review feedback from the review conversation's threads, in FreeScout or HelpScout. * * @param \WP_Post $post The plugin post. * @param bool $full_history Whether to return the full conversation history. * @return array */ private static function get_feedback( \WP_Post $post, bool $full_history = false ): array { - if ( ! class_exists( Helpscout::class ) || ! class_exists( HelpScout_Client::class ) ) { + if ( ! class_exists( Helpscout::class ) || ! class_exists( HelpScout_Client::class ) || ! class_exists( FreeScout_Client::class ) ) { return array(); } @@ -263,7 +264,9 @@ private static function get_feedback( \WP_Post $post, bool $full_history = false continue; } - $threads = self::get_conversation_threads( (int) $email->id ); + $threads = empty( $email->freescout ) + ? self::get_conversation_threads( (int) $email->id ) + : self::get_freescout_threads( (int) $email->id ); if ( ! $threads ) { continue; @@ -395,6 +398,46 @@ private static function get_conversation_threads( int $conversation_id ): ?array return $threads; } + /** + * Fetch conversation threads from FreeScout, with short-lived caching, shaped like HelpScout's. + * + * The API lists drafts too, which the author hasn't been sent, so only `published` threads are kept. + * + * @param int $conversation_id The FreeScout conversation ID. + * @return array|null Array of thread objects or null on failure. + */ + private static function get_freescout_threads( int $conversation_id ): ?array { + $cache_key = 'freescout_threads_' . $conversation_id; + $cached = get_site_transient( $cache_key ); + + if ( false !== $cached ) { + return $cached; + } + + $response = FreeScout_Client::api( 'conversations/' . $conversation_id, array( 'embed' => 'threads' ), 'GET', $response_code ); + + if ( 200 !== $response_code || empty( $response['_embedded']['threads'] ) || ! is_array( $response['_embedded']['threads'] ) ) { + return null; + } + + $threads = array(); + foreach ( $response['_embedded']['threads'] as $thread ) { + if ( ! is_array( $thread ) || 'published' !== ( $thread['state'] ?? '' ) ) { + continue; + } + + $threads[] = (object) array( + 'type' => (string) ( $thread['type'] ?? '' ), + 'body' => (string) ( $thread['body'] ?? '' ), + 'createdAt' => (string) ( $thread['createdAt'] ?? '' ), + ); + } + + set_site_transient( $cache_key, $threads, 5 * MINUTE_IN_SECONDS ); + + return $threads; + } + /** * Wrap feedback text in delimiters to separate content from instructions. *