From 8a5b180004c500ccbf9a80a1a92bba44f3f5ef53 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 20 Oct 2026 16:52:03 -0500 Subject: [PATCH 1/3] FreeScout: Open HelpScout's links to conversations in FreeScout HelpScout's links, `secure.helpscout.net/conversation/{ID}/{number}/`, are all over Slack, Trac, and docs. Imported conversations keep HelpScout's number but not its ID, so swapping in FreeScout's address opened nothing, or the wrong conversation. WPOrgSite now redirects `/conversation/{ID}/{number}` and `/conversation/{ID}` to the conversation the HelpScout one was imported as, for agents who may see it. It keeps its own table of HelpScout IDs, copied from the importer's and added to as the importer saves more, so links keep working once the importer is removed. Co-Authored-By: Claude Opus 5.5 --- freescout.wordpress.net/AGENTS.md | 2 +- .../Modules/WPOrgHelpScoutImport/README.md | 2 +- ...porgsite_helpscout_conversations_table.php | 50 +++++++ .../Controllers/HelpScoutLinksController.php | 39 ++++++ .../Middleware/RedirectHelpScoutLinks.php | 52 ++++++++ .../Modules/WPOrgSite/Http/routes.php | 27 ++++ .../Providers/WPOrgSiteServiceProvider.php | 29 ++++ .../Modules/WPOrgSite/README.md | 10 ++ .../WPOrgSite/Services/HelpScoutLinks.php | 83 ++++++++++++ .../Modules/WPOrgSite/module.json | 2 +- .../WPOrgSite/tests/HelpScoutLinksTest.php | 124 ++++++++++++++++++ 11 files changed, 417 insertions(+), 3 deletions(-) create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php create mode 100644 freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 269363033c..80bda17ed0 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -14,7 +14,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `WPOrgPluginReview` — the latest plugin review of a conversation in its sidebar, for the plugins team: the details, flags, and issues of its review emails' Review ID lines. It indexes which replies are review emails, and of what type; `php artisan wporgpluginreview:index` indexes them again. Only in the plugins team's mailbox, and, with the Teams module, only for members of the teams whose names start with "Plugin". Issue names and replies to copy are in its `Services/IssueNames.php` and `Services/FlagReplies.php`. The index keeps each review email's plugin ID too, which the API's conversation list takes as `reviewPluginId`. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `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: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). + - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, HelpScout's links to conversations, redirected to the imported ones, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). - `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, and keeps the copy of conversations (`wporg_helpscout`) the plugin directory reads. - `tests/` — shared PHPUnit bootstrap and base `TestCase`. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index 8b421e41fd..cb6e02c97f 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -68,6 +68,6 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's 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. +- After the last mailbox has moved, switch the module off, and remove its code in a later deploy. Imported conversations keep HelpScout's numbers. WPOrgSite keeps its own copy of which conversation each HelpScout ID was imported as, so HelpScout's links with FreeScout's address keep working after the importer's tables are gone. To try it locally, start the environment with `WPORG_HELPSCOUT_APP_ID` and `WPORG_HELPSCOUT_APP_SECRET` set. The import only reads from HelpScout. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php new file mode 100644 index 0000000000..89458d6c5e --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php @@ -0,0 +1,50 @@ +unsignedBigInteger( 'helpscout_id' )->primary(); + $table->unsignedInteger( 'conversation_id' )->index(); + } + ); + + // The importer adds the ones it imports from now on. + HelpScoutLinks::copy_imported(); + } + + /** + * Reverts the migration. + * + * @return void + */ + public function down(): void { + Schema::dropIfExists( HelpScoutLinks::TABLE ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php new file mode 100644 index 0000000000..e3b1b10a72 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Controllers/HelpScoutLinksController.php @@ -0,0 +1,39 @@ +user(); + $conversation = $user instanceof User ? HelpScoutLinks::conversation( (int) $helpscout_id, $user ) : null; + if ( ! $conversation ) { + abort( 404 ); + } + + return redirect()->away( $conversation->url() ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php new file mode 100644 index 0000000000..a55d746e16 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php @@ -0,0 +1,52 @@ +route(); + $user = $request->user(); + + // Guests are left to the route's auth middleware, which brings them back here after logging in. + if ( ! $route || 'conversations.view' !== $route->getName() || ! $user instanceof User ) { + return $next( $request ); + } + + try { + $id = (int) $route->parameter( 'id' ); + $conversation = $id && ! Conversation::whereKey( $id )->exists() ? HelpScoutLinks::conversation( $id, $user ) : null; + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgSite] Could not look up a HelpScout conversation: ' . $e->getMessage() ); + $conversation = null; + } + + return $conversation ? redirect()->away( $conversation->url() ) : $next( $request ); + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php new file mode 100644 index 0000000000..ca79eb3f90 --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php @@ -0,0 +1,27 @@ + array( 'web', 'auth' ), + 'prefix' => \Helper::getSubdirectory(), + 'namespace' => 'Modules\WPOrgSite\Http\Controllers', + ), + static function (): void { + // HelpScout's links, with FreeScout's address; `/conversation/{ID}` alone is core's route. + Route::get( '/conversation/{helpscout_id}/{number}', 'HelpScoutLinksController@show' ) + ->where( + array( + 'helpscout_id' => '[0-9]+', + 'number' => '[0-9]+', + ) + ) + ->name( 'wporgsite.helpscout_link' ); + } +); diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php index 9a992c7846..401da11a87 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Providers/WPOrgSiteServiceProvider.php @@ -12,12 +12,15 @@ use App\Conversation; use App\Thread; use App\User; +use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; use Illuminate\View\View; use Modules\WPOrgSite\Http\Middleware\LockModules; +use Modules\WPOrgSite\Http\Middleware\RedirectHelpScoutLinks; use Modules\WPOrgSite\Services\AltConversation; use Modules\WPOrgSite\Services\EnvSettings; +use Modules\WPOrgSite\Services\HelpScoutLinks; use Modules\WPOrgSite\Services\PinnedNote; use Modules\WPOrgSite\Services\UserChoice; @@ -64,7 +67,10 @@ final class WPOrgSiteServiceProvider extends ServiceProvider { */ public function boot(): void { $this->app['router']->pushMiddlewareToGroup( 'web', LockModules::class ); + $this->app['router']->pushMiddlewareToGroup( 'web', RedirectHelpScoutLinks::class ); $this->loadViewsFrom( __DIR__ . '/../Resources/views', self::ALIAS ); + $this->loadRoutesFrom( __DIR__ . '/../Http/routes.php' ); + $this->loadMigrationsFrom( __DIR__ . '/../Database/Migrations' ); // Modules come with deploys, so the Modules page only lists installed ones; site.css hides the empty directory. \View::composer( @@ -126,6 +132,7 @@ static function (): void { self::register_profile_hooks(); self::register_settings_hooks(); self::register_pinned_note_hooks(); + self::register_helpscout_link_hooks(); // For alt-conversation.js, which lays the conversation out. \Eventy::addAction( @@ -344,6 +351,28 @@ static function ( mixed $thread = null ): void { ); } + /** + * Records the HelpScout ID of each conversation the importer imports, for HelpScout's links. + * + * The importer's model is named rather than used: the importer goes once every mailbox has moved. + * + * @return void + */ + private static function register_helpscout_link_hooks(): void { + \Event::listen( + 'eloquent.saved: ' . HelpScoutLinks::IMPORTER_MODEL, + static function ( mixed $imported = null ): void { + try { + if ( $imported instanceof Model ) { + HelpScoutLinks::record( (int) $imported->getAttribute( 'helpscout_id' ), (int) $imported->getAttribute( 'conversation_id' ) ); + } + } catch ( \Throwable $e ) { + \Log::error( '[WPOrgSite] Could not record the HelpScout ID of an imported conversation: ' . $e->getMessage() ); + } + } + ); + } + /** * The copy of a note above its conversation's threads: its body as core shows it in the thread, with modules' * changes to it, and links to its attachments. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/README.md b/freescout.wordpress.net/Modules/WPOrgSite/README.md index 5495195375..dcb2694200 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/README.md +++ b/freescout.wordpress.net/Modules/WPOrgSite/README.md @@ -7,6 +7,7 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own - **Modules page:** only installed modules are listed, without FreeScout's Modules Directory and Marketplace. They can't be updated or deleted there; that happens on the server. Switching them on and off still works. - **Modules page layout:** the module cards are laid out in columns, as many as the window fits. - **Settings kept in `.env`:** FreeScout saves some of Manage » Settings to `.env`, like the time zone, the language, conversation numbers, and the fetching interval, and so do modules for some of theirs. The server's `.env` is read-only, so saving one failed, and with it the rest of its section. They show read-only, with their value and a note to ask the systems team, and saving a section leaves them out, so the rest of it saves. It's in `Services/EnvSettings.php` and `Public/js/settings.js`. +- **HelpScout's links:** [a HelpScout link with FreeScout's address opens the conversation it was imported as](#helpscouts-links). - **Branding:** the WordPress mark in the top left and, on a blue tile, as the favicon. The login page keeps FreeScout's logo, so it doesn't pass for a WordPress.org login, redrawn as an SVG so it's sharp on high-resolution screens. - **Saved replies:** [chosen from the keyboard](#saved-replies-from-the-keyboard), with `/` in the editor. - **Discarding a reply or note:** an empty one, without text, images, or attachments, is discarded at once, without asking. Otherwise, core's "Discard this draft?" dialog opens next to the discard button, rather than at the top of the window, a long way from it; so it does for the drafts' Discard links. For an empty one, the dialog is answered without showing, so core discards it as it would. **Escape** discards it too, as its button does, from the editor or with nothing in particular focused, not from the form's other fields, like the recipients': in the dialog, Yes has the focus, so Enter confirms, and Escape again cancels, back to where the caret was. Escape that closes something first, like the Saved Replies menu, only closes that. It's in `Public/js/discard.js`. @@ -16,6 +17,15 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own - **Alternate conversation design:** agents who choose so in their profile get [conversations laid out otherwise](#alternate-conversation-design), oldest email first, with Reply and Note at the bottom. - **Conversations:** [keyboard shortcuts](#keyboard-shortcuts); the status and assignee selectors pinned to the top of the window while their menu is open, further down long conversations where the toolbar has scrolled out of view, so they can be chosen there, like after pressing `s` or `a`; the subject at 18px and the threads' authors at 16px, rather than core's 23px and 18px, so more of the conversation fits; and clicking the number next to the subject copies it, without the `#`. Its tooltip says when it's copied; where the browser doesn't let the page write to the clipboard, it copies through a selected field instead. It's all in `Public/js/conversation.js`. +## HelpScout's links + +HelpScout's links to conversations, `https://secure.helpscout.net/conversation/{ID}/{number}/`, are in Slack, Trac, and docs. Imported conversations keep HelpScout's number, but not its ID, which is HelpScout's own. With FreeScout's address in front, `https://freescout.wordpress.net/conversation/{ID}/{number}/`, or `/conversation/{ID}` without the number, the link redirects to the conversation the HelpScout one was imported as. + +- **The IDs** are kept in the `wporgsite_helpscout_conversations` table, because the importer's own goes with it. Its migration copies the importer's, and the conversations the importer imports from then on are added as it saves them, through the `eloquent.saved` event of its model, named rather than used, so nothing breaks once it's gone. It's in `Services/HelpScoutLinks.php`. +- **`/conversation/{ID}/{number}`** is this module's route, in `Http/Controllers/HelpScoutLinksController.php`. The ID says which conversation it is, so the number is ignored. +- **`/conversation/{ID}`** is core's route, which would answer with a 404. `Http/Middleware/RedirectHelpScoutLinks.php` redirects it first, when there's no FreeScout conversation with that ID. HelpScout's IDs are 9 or 10 digits, far beyond FreeScout's, so they don't stand for one. +- **Agents** are redirected once they're logged in, and only to a conversation they may see; otherwise, it's a 404. + ## Keyboard shortcuts In a conversation, while the agent isn't typing in a field or the editor, and no modal is open: diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php new file mode 100644 index 0000000000..a7b8f922fb --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php @@ -0,0 +1,83 @@ +updateOrInsert( array( 'helpscout_id' => $helpscout_id ), array( 'conversation_id' => $conversation_id ) ); + } + + /** + * Copies the importer's IDs, of the conversations it imported so far, into the new, empty table. + * + * @return void + */ + public static function copy_imported(): void { + if ( ! Schema::hasTable( self::IMPORTER_TABLE ) ) { + return; + } + + $grammar = \DB::getQueryGrammar(); + \DB::statement( 'INSERT INTO ' . $grammar->wrapTable( self::TABLE ) . ' (helpscout_id, conversation_id) SELECT helpscout_id, conversation_id FROM ' . $grammar->wrapTable( self::IMPORTER_TABLE ) ); + } + + /** + * The conversation a HelpScout conversation was imported as, if the agent may see it. + * + * @param int $helpscout_id HelpScout's conversation ID. + * @param User $user Agent. + * @return Conversation|null The conversation, or null if there's none they may see. + */ + public static function conversation( int $helpscout_id, User $user ): ?Conversation { + $conversation_id = \DB::table( self::TABLE )->where( 'helpscout_id', $helpscout_id )->value( 'conversation_id' ); + $conversation = $conversation_id ? Conversation::find( (int) $conversation_id ) : null; + + return $conversation instanceof Conversation && $user->can( 'view', $conversation ) ? $conversation : null; + } +} diff --git a/freescout.wordpress.net/Modules/WPOrgSite/module.json b/freescout.wordpress.net/Modules/WPOrgSite/module.json index 5597cb7209..a4923ea054 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/module.json +++ b/freescout.wordpress.net/Modules/WPOrgSite/module.json @@ -3,7 +3,7 @@ "alias": "wporgsite", "img": "/modules/wporgsite/img/icon.svg", "description": "Adapts FreeScout to how WordPress.org runs it, e.g. modules come from deploys, not from updates here.", - "version": "1.1.0", + "version": "1.2.0", "detailsUrl": "https://github.com/WordPress/wordpress.org/tree/trunk/freescout.wordpress.net/Modules/WPOrgSite", "author": "WordPress.org", "authorUrl": "https://wordpress.org/", diff --git a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php new file mode 100644 index 0000000000..da8a39340a --- /dev/null +++ b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php @@ -0,0 +1,124 @@ +app->register( WPOrgSiteServiceProvider::class ); + $this->app['router']->getRoutes()->refreshNameLookups(); + + $this->conversation = $this->create_conversation( $this->create_mailbox(), $this->create_sender() ); + HelpScoutLinks::record( self::HELPSCOUT_ID, (int) $this->conversation->id ); + } + + /** + * Both forms of a HelpScout link open the conversation. + * + * @dataProvider data_links + * + * @param string $path Link's path. + * @return void + */ + public function test_redirects_to_conversation( string $path ): void { + $this->actingAs( $this->create_user() ); + + $this->get( $path )->assertRedirect( $this->conversation->url() ); + } + + /** + * HelpScout's links: as HelpScout made them, and with only the ID, which is core's route. + * + * @return array> + */ + public static function data_links(): array { + return array( + 'ID and number' => array( '/conversation/' . self::HELPSCOUT_ID . '/123456/?folderId=42' ), + 'ID only' => array( '/conversation/' . self::HELPSCOUT_ID ), + ); + } + + /** + * An agent without access to the conversation's mailbox isn't told where it is. + * + * @return void + */ + public function test_hides_conversation_from_agent_without_access(): void { + $this->actingAs( $this->create_user( User::ROLE_USER ) ); + + $this->get( '/conversation/' . self::HELPSCOUT_ID . '/123456' )->assertStatus( 404 ); + } + + /** + * The importer's IDs are copied as the table is created, and those it saves later are added. + * + * @return void + */ + public function test_keeps_importer_ids(): void { + \DB::table( HelpScoutLinks::TABLE )->delete(); + \DB::table( HelpScoutLinks::IMPORTER_TABLE )->insert( + array( + 'helpscout_id' => self::HELPSCOUT_ID, + 'helpscout_number' => 123456, + 'conversation_id' => $this->conversation->id, + ) + ); + + HelpScoutLinks::copy_imported(); + + $imported = $this->create_conversation( $this->create_mailbox( 'Data' ), $this->create_sender() ); + ImportedConversation::query()->create( + array( + 'helpscout_id' => self::HELPSCOUT_ID + 1, + 'helpscout_number' => 123457, + 'conversation_id' => $imported->id, + ) + ); + + $this->assertSame( + array( + self::HELPSCOUT_ID => (int) $this->conversation->id, + self::HELPSCOUT_ID + 1 => (int) $imported->id, + ), + \DB::table( HelpScoutLinks::TABLE )->orderBy( 'helpscout_id' )->pluck( 'conversation_id', 'helpscout_id' )->map( 'intval' )->all() + ); + } +} From 1748fa69ed0de7a735ebdfbd499d24814ce9f60a Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 21 Oct 2026 10:16:12 -0500 Subject: [PATCH 2/3] FreeScout: Look HelpScout IDs up only after core's 404 The middleware for `/conversation/{ID}` checked whether a FreeScout conversation had that ID before core did, so every conversation view ran that lookup twice. Let core answer first, and look the ID up as a HelpScout one only when core has no such conversation. Viewing a conversation is back to core's own queries, and a FreeScout conversation still can't be redirected, which the new test checks. Co-Authored-By: Claude Opus 5.5 --- .../Middleware/RedirectHelpScoutLinks.php | 20 +++++++++---------- .../Modules/WPOrgSite/README.md | 2 +- .../WPOrgSite/tests/HelpScoutLinksTest.php | 19 ++++++++++++++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php index a55d746e16..3eed988959 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php @@ -9,7 +9,6 @@ namespace Modules\WPOrgSite\Http\Middleware; -use App\Conversation; use App\User; use Closure; use Illuminate\Http\Request; @@ -17,9 +16,9 @@ use Symfony\Component\HttpFoundation\Response; /** - * Redirects `/conversation/{HelpScout ID}`, which core would answer with a 404, to the conversation it was imported as. + * Redirects `/conversation/{HelpScout ID}`, which core answers with a 404, to the conversation it was imported as. * - * HelpScout's IDs are 9 or 10 digits, far beyond FreeScout's, so they don't stand for a FreeScout conversation too. + * It only looks once core has answered, so viewing a FreeScout conversation costs nothing extra. */ final class RedirectHelpScoutLinks { @@ -31,22 +30,23 @@ final class RedirectHelpScoutLinks { * @return Response */ public function handle( Request $request, Closure $next ): Response { - $route = $request->route(); - $user = $request->user(); + $response = $next( $request ); + $route = $request->route(); + $user = $request->user(); - // Guests are left to the route's auth middleware, which brings them back here after logging in. - if ( ! $route || 'conversations.view' !== $route->getName() || ! $user instanceof User ) { - return $next( $request ); + // Guests get the login redirect rather than a 404, and come back here once logged in. + if ( 404 !== $response->getStatusCode() || ! $route || 'conversations.view' !== $route->getName() || ! $user instanceof User ) { + return $response; } try { $id = (int) $route->parameter( 'id' ); - $conversation = $id && ! Conversation::whereKey( $id )->exists() ? HelpScoutLinks::conversation( $id, $user ) : null; + $conversation = $id ? HelpScoutLinks::conversation( $id, $user ) : null; } catch ( \Throwable $e ) { \Log::error( '[WPOrgSite] Could not look up a HelpScout conversation: ' . $e->getMessage() ); $conversation = null; } - return $conversation ? redirect()->away( $conversation->url() ) : $next( $request ); + return $conversation ? redirect()->away( $conversation->url() ) : $response; } } diff --git a/freescout.wordpress.net/Modules/WPOrgSite/README.md b/freescout.wordpress.net/Modules/WPOrgSite/README.md index dcb2694200..926ce5c8f2 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/README.md +++ b/freescout.wordpress.net/Modules/WPOrgSite/README.md @@ -23,7 +23,7 @@ HelpScout's links to conversations, `https://secure.helpscout.net/conversation/{ - **The IDs** are kept in the `wporgsite_helpscout_conversations` table, because the importer's own goes with it. Its migration copies the importer's, and the conversations the importer imports from then on are added as it saves them, through the `eloquent.saved` event of its model, named rather than used, so nothing breaks once it's gone. It's in `Services/HelpScoutLinks.php`. - **`/conversation/{ID}/{number}`** is this module's route, in `Http/Controllers/HelpScoutLinksController.php`. The ID says which conversation it is, so the number is ignored. -- **`/conversation/{ID}`** is core's route, which would answer with a 404. `Http/Middleware/RedirectHelpScoutLinks.php` redirects it first, when there's no FreeScout conversation with that ID. HelpScout's IDs are 9 or 10 digits, far beyond FreeScout's, so they don't stand for one. +- **`/conversation/{ID}`** is core's route, which answers with a 404 when there's no FreeScout conversation with that ID. `Http/Middleware/RedirectHelpScoutLinks.php` turns that 404 into the redirect. It only looks once core has answered, so viewing a FreeScout conversation costs nothing extra. - **Agents** are redirected once they're logged in, and only to a conversation they may see; otherwise, it's a 404. ## Keyboard shortcuts diff --git a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php index da8a39340a..2c6f6e726a 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php @@ -76,6 +76,25 @@ public static function data_links(): array { ); } + /** + * A FreeScout conversation isn't redirected, even when its ID is also a recorded HelpScout ID. + * + * @return void + */ + public function test_leaves_freescout_conversations_alone(): void { + $other = $this->create_conversation( $this->create_mailbox( 'Data' ), $this->create_sender() ); + HelpScoutLinks::record( (int) $this->conversation->id, (int) $other->id ); + $this->actingAs( $this->create_user() ); + + \DB::enableQueryLog(); + $response = $this->get( $this->conversation->url() ); + $queries = implode( "\n", array_column( \DB::getQueryLog(), 'query' ) ); + + // Core's template hits PHP 8.4 deprecations under test, so only the redirect is checked, not the page. + $this->assertFalse( $response->isRedirect() ); + $this->assertStringNotContainsString( HelpScoutLinks::TABLE, $queries ); + } + /** * An agent without access to the conversation's mailbox isn't told where it is. * From ac2ff63ce091c409f9d48611b9ae659b82547b56 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Wed, 21 Oct 2026 10:19:06 -0500 Subject: [PATCH 3/3] FreeScout: Tighten the HelpScout links docs and comments Shorten the README section, the importer README and AGENTS.md entries, and the docblocks to what a reader needs. Drop the middleware's guest comment, which no longer explains anything now that it acts only on core's 404, and correct the migration's, which credited the importer with recording later imports. Co-Authored-By: Claude Opus 5.5 --- freescout.wordpress.net/AGENTS.md | 2 +- .../Modules/WPOrgHelpScoutImport/README.md | 2 +- ..._create_wporgsite_helpscout_conversations_table.php | 2 +- .../Http/Middleware/RedirectHelpScoutLinks.php | 5 ++--- .../Modules/WPOrgSite/Http/routes.php | 2 +- freescout.wordpress.net/Modules/WPOrgSite/README.md | 10 +++++----- .../Modules/WPOrgSite/Services/HelpScoutLinks.php | 7 +++---- .../Modules/WPOrgSite/tests/HelpScoutLinksTest.php | 2 +- 8 files changed, 15 insertions(+), 17 deletions(-) diff --git a/freescout.wordpress.net/AGENTS.md b/freescout.wordpress.net/AGENTS.md index 80bda17ed0..ff9319a8ad 100644 --- a/freescout.wordpress.net/AGENTS.md +++ b/freescout.wordpress.net/AGENTS.md @@ -14,7 +14,7 @@ Context for AI coding agents and developers working on the WordPress.org FreeSco - `WPOrgPluginReview` — the latest plugin review of a conversation in its sidebar, for the plugins team: the details, flags, and issues of its review emails' Review ID lines. It indexes which replies are review emails, and of what type; `php artisan wporgpluginreview:index` indexes them again. Only in the plugins team's mailbox, and, with the Teams module, only for members of the teams whose names start with "Plugin". Issue names and replies to copy are in its `Services/IssueNames.php` and `Services/FlagReplies.php`. The index keeps each review email's plugin ID too, which the API's conversation list takes as `reviewPluginId`. - `WPOrgSearch` — gives searches without other filters core's After filter, a year ago unless the agent picks a date, and runs core's conversation search through its `search.conversations.perform` hook in one query with `COUNT(*) OVER()`, stopped after 20 seconds on MariaDB. - `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: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, HelpScout's links to conversations, redirected to the imported ones, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). + - `WPOrgSite` — tweaks for how WordPress.org runs FreeScout, rather than features; add new ones here instead of starting a module. So far: the WordPress mark, a Modules page that lists only installed modules and neither updates nor deletes them, redirects for HelpScout's conversation links, and conversation UX (keyboard shortcuts, `/` for saved replies, a collapsible sidebar, pinned notes and headers, an alternate design), each documented in its README along with the core gotchas they work around (`#app`'s `overflow: hidden`, Summernote's caret, core's global `scrollTo()`). - `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, and keeps the copy of conversations (`wporg_helpscout`) the plugin directory reads. - `tests/` — shared PHPUnit bootstrap and base `TestCase`. diff --git a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md index cb6e02c97f..f4895b9d0a 100644 --- a/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md +++ b/freescout.wordpress.net/Modules/WPOrgHelpScoutImport/README.md @@ -68,6 +68,6 @@ Copies a HelpScout mailbox's conversations into a FreeScout mailbox, so a team's 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. WPOrgSite keeps its own copy of which conversation each HelpScout ID was imported as, so HelpScout's links with FreeScout's address keep working after the importer's tables are gone. +- After the last mailbox has moved, switch the module off, and remove its code in a later deploy. Imported conversations keep HelpScout's numbers. WPOrgSite keeps its own copy of the HelpScout IDs, so HelpScout's links keep working once these tables are gone. To try it locally, start the environment with `WPORG_HELPSCOUT_APP_ID` and `WPORG_HELPSCOUT_APP_SECRET` set. The import only reads from HelpScout. diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php index 89458d6c5e..22cf296f35 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Database/Migrations/2026_10_20_000000_create_wporgsite_helpscout_conversations_table.php @@ -35,7 +35,7 @@ static function ( Blueprint $table ): void { } ); - // The importer adds the ones it imports from now on. + // Later imports are recorded as the importer saves them. HelpScoutLinks::copy_imported(); } diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php index 3eed988959..9792fcf605 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/Middleware/RedirectHelpScoutLinks.php @@ -1,6 +1,6 @@ route(); $user = $request->user(); - // Guests get the login redirect rather than a 404, and come back here once logged in. if ( 404 !== $response->getStatusCode() || ! $route || 'conversations.view' !== $route->getName() || ! $user instanceof User ) { return $response; } diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php index ca79eb3f90..323cdc256a 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Http/routes.php @@ -14,7 +14,7 @@ 'namespace' => 'Modules\WPOrgSite\Http\Controllers', ), static function (): void { - // HelpScout's links, with FreeScout's address; `/conversation/{ID}` alone is core's route. + // `/conversation/{ID}` alone is core's route, handled by the RedirectHelpScoutLinks middleware. Route::get( '/conversation/{helpscout_id}/{number}', 'HelpScoutLinksController@show' ) ->where( array( diff --git a/freescout.wordpress.net/Modules/WPOrgSite/README.md b/freescout.wordpress.net/Modules/WPOrgSite/README.md index 926ce5c8f2..ca3553d2f9 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/README.md +++ b/freescout.wordpress.net/Modules/WPOrgSite/README.md @@ -19,12 +19,12 @@ Adapts FreeScout to how WordPress.org runs it. Small changes to FreeScout's own ## HelpScout's links -HelpScout's links to conversations, `https://secure.helpscout.net/conversation/{ID}/{number}/`, are in Slack, Trac, and docs. Imported conversations keep HelpScout's number, but not its ID, which is HelpScout's own. With FreeScout's address in front, `https://freescout.wordpress.net/conversation/{ID}/{number}/`, or `/conversation/{ID}` without the number, the link redirects to the conversation the HelpScout one was imported as. +HelpScout's links, `https://secure.helpscout.net/conversation/{ID}/{number}/`, are all over Slack, Trac, and docs. Imported conversations keep HelpScout's number, but not its ID. With FreeScout's address in front, with or without the number, such a link redirects to the conversation it was imported as. -- **The IDs** are kept in the `wporgsite_helpscout_conversations` table, because the importer's own goes with it. Its migration copies the importer's, and the conversations the importer imports from then on are added as it saves them, through the `eloquent.saved` event of its model, named rather than used, so nothing breaks once it's gone. It's in `Services/HelpScoutLinks.php`. -- **`/conversation/{ID}/{number}`** is this module's route, in `Http/Controllers/HelpScoutLinksController.php`. The ID says which conversation it is, so the number is ignored. -- **`/conversation/{ID}`** is core's route, which answers with a 404 when there's no FreeScout conversation with that ID. `Http/Middleware/RedirectHelpScoutLinks.php` turns that 404 into the redirect. It only looks once core has answered, so viewing a FreeScout conversation costs nothing extra. -- **Agents** are redirected once they're logged in, and only to a conversation they may see; otherwise, it's a 404. +- **The IDs** are in the `wporgsite_helpscout_conversations` table, so they outlive the importer. Its migration copies the importer's table, and later imports are added through the `eloquent.saved` event of the importer's model. It's in `Services/HelpScoutLinks.php`. +- **`/conversation/{ID}/{number}`** is this module's route, in `Http/Controllers/HelpScoutLinksController.php`; the number is ignored. +- **`/conversation/{ID}`** is core's route. Where core answers with a 404, `Http/Middleware/RedirectHelpScoutLinks.php` redirects instead, so FreeScout's own conversations are untouched. +- **Agents** are redirected once logged in, and only to a conversation they may see; otherwise, it's a 404. ## Keyboard shortcuts diff --git a/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php index a7b8f922fb..cd0aa4692f 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/Services/HelpScoutLinks.php @@ -16,8 +16,7 @@ /** * Finds the FreeScout conversation a HelpScout conversation was imported as, from the HelpScout ID in its links. * - * HelpScout's links, `https://secure.helpscout.net/conversation/{ID}/{number}/`, keep working with FreeScout's address - * in front. The importer's own table of IDs goes with it, so this keeps a copy. + * The importer's table of IDs goes with the importer, so this keeps a copy. */ final class HelpScoutLinks { @@ -46,7 +45,7 @@ final class HelpScoutLinks { * Records the conversation a HelpScout conversation was imported as. * * @param int $helpscout_id HelpScout's conversation ID. - * @param int $conversation_id FreeScout's. + * @param int $conversation_id FreeScout's conversation ID. * @return void */ public static function record( int $helpscout_id, int $conversation_id ): void { @@ -54,7 +53,7 @@ public static function record( int $helpscout_id, int $conversation_id ): void { } /** - * Copies the importer's IDs, of the conversations it imported so far, into the new, empty table. + * Copies the IDs the importer has recorded so far into the new table. * * @return void */ diff --git a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php index 2c6f6e726a..b28a0a4a10 100644 --- a/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php +++ b/freescout.wordpress.net/Modules/WPOrgSite/tests/HelpScoutLinksTest.php @@ -90,7 +90,7 @@ public function test_leaves_freescout_conversations_alone(): void { $response = $this->get( $this->conversation->url() ); $queries = implode( "\n", array_column( \DB::getQueryLog(), 'query' ) ); - // Core's template hits PHP 8.4 deprecations under test, so only the redirect is checked, not the page. + // Core's template fails on PHP 8.4 deprecations under test, so only the redirect is checked. $this->assertFalse( $response->isRedirect() ); $this->assertStringNotContainsString( HelpScoutLinks::TABLE, $queries ); }