From c111926f564792bd164f077e51a9c3a1c970c720 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 10:13:35 +0800 Subject: [PATCH] feat(channels): expose oauth callbacks --- cmd/route_parity/main.go | 5 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 +- docs/parity/gochat_routes.txt | 7 +- docs/parity/route_parity.md | 7 +- internal/router/channel_callbacks.go | 460 +++++++++++++++++++++++ internal/router/router.go | 5 + internal/router/router_test.go | 188 +++++++++ 7 files changed, 678 insertions(+), 8 deletions(-) create mode 100644 internal/router/channel_callbacks.go diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index e01ed77f..db29e2c2 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -38,6 +38,11 @@ var criticalRoutes = []route{ {Method: "GET", Path: "/linear/callback", Controller: "linear/callbacks#show", Source: "routes.rb:630"}, {Method: "GET", Path: "/shopify/callback", Controller: "shopify/callbacks#show", Source: "routes.rb:634"}, {Method: "GET", Path: "/notion/callback", Controller: "notion/callbacks#show", Source: "routes.rb:654"}, + {Method: "GET", Path: "/twitter/callback", Controller: "twitter/callbacks#show", Source: "routes.rb:626"}, + {Method: "GET", Path: "/microsoft/callback", Controller: "microsoft/callbacks#show", Source: "routes.rb:649"}, + {Method: "GET", Path: "/google/callback", Controller: "google/callbacks#show", Source: "routes.rb:650"}, + {Method: "GET", Path: "/instagram/callback", Controller: "instagram/callbacks#show", Source: "routes.rb:651"}, + {Method: "GET", Path: "/tiktok/callback", Controller: "tiktok/callbacks#show", Source: "routes.rb:652"}, {Method: "POST", Path: "/api/v1/accounts/", Controller: "api/v1/accounts#create", Source: "routes.rb:47"}, {Method: "GET", Path: "/api/v1/accounts/:account_id", Controller: "api/v1/accounts#show", Source: "routes.rb:47"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 96dc7062..40edcc71 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,14 +49,14 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(integrations): expose oauth callbacks`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(integrations): expose oauth callbacks`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(channels): expose oauth callbacks`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(channels): expose oauth callbacks`. - Latest documentation/tooling checkpoint: `docs: land full parity tracking plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`; root integration callback drift is implemented for `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, and `routes.rb:630/634/654`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, `/twilio/voice/recording_status/:phone`, `/linear/callback`, `/shopify/callback`, and `/notion/callback`. Linear, Shopify, and Notion callbacks verify account state, exchange OAuth codes through provider token endpoints, persist Chatwoot-style account integration hooks, and redirect to reused frontend integration settings; Shopify account integration auth now returns the Chatwoot `redirect_url` authorization payload instead of creating credentials directly. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder burn-down, or B12 live smoke finding. +- Worktree status at this implementation checkpoint: P2/P3 external service verification route drift is now implemented for Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `CustomDomainsController#verify`, and `routes.rb:657-660`; Twilio callback route drift is implemented for `Twilio::CallbackController`, `Twilio::DeliveryStatusController`, and `routes.rb:639-640`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`; root integration callback drift is implemented for `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, and `routes.rb:630/634/654`; root channel callback drift is implemented for `Twitter::CallbacksController`, `Google::CallbacksController`, `Microsoft::CallbacksController`, `Instagram::CallbacksController`, `Tiktok::CallbacksController`, and `routes.rb:626/649-652`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, `/.well-known/cf-custom-hostname-challenge/:id`, `/twilio/callback`, `/twilio/delivery_status`, `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, `/twilio/voice/recording_status/:phone`, `/linear/callback`, `/shopify/callback`, `/notion/callback`, `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`. Linear, Shopify, and Notion callbacks verify account state, exchange OAuth codes through provider token endpoints, persist Chatwoot-style account integration hooks, and redirect to reused frontend integration settings; Shopify account integration auth now returns the Chatwoot `redirect_url` authorization payload instead of creating credentials directly. Google and Microsoft root callbacks exchange OAuth codes, decode ID-token mailbox identity, create/update email channel inboxes, and redirect to settings or agents. Instagram, TikTok, and Twitter root callbacks create/update channel inboxes, preserve Chatwoot error redirects to new-inbox pages, and route successful new inboxes to agents. P2/P3 dashboard deep-link route drift is in Review. P3.8/P6.6l public help-center sitemap drift is in Review. P6.6g public article lists, P6.6h public categories, P6.6i public portal show, P6.6j public portal search, P6.6k public article show/markdown/tracking, P3.5 company avatar form/pagination drift, P3.11 account integrations apps/hooks API, P3.10 account webhook API, P3.9 account agent-bot API, P6.8 contact outbound voice call, P3.7d enterprise help-center article translation, P6.5 inbox agent-bot member actions, P6 message retry parity, P6 conversation custom-attribute response parity, and P3.7a-P3.7c help-center payload parity remain in Review. The fresh placeholder audit found remaining `chatwootParityStub` routes only in webhook handler-not-injected fallbacks, not in the reused dashboard account/contact/conversation/message/inbox critical path. P3.6 custom filters/custom attribute definitions and label CRUD are already in Review. B11.1a-B11.3e Captain/Copilot slices remain in Review; B12.1/B12.2/B12.3 smoke harnesses remain in Review; P5 durable job work through P5.13b is in Review. Next active implementation slice is Phase 6 placeholder burn-down, B12 live smoke finding, or the next route/controller/serializer drift from fresh reference evidence. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 895` after adding Chatwoot root Linear, Shopify, and Notion callback routes. +- Route dump succeeds with `TOTAL: 900` after adding Chatwoot root Twitter, Google, Microsoft, Instagram, and TikTok callback routes. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- Tracked frontend-critical route audit covers 357 Chatwoot routes: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. +- Tracked frontend-critical route audit covers 362 Chatwoot routes: 353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing. The 9 parameter-compatible routes are Gin-internal parameter-name differences for nested AgentCapacityPolicy users/inbox limits plus the public article `.md`/`.png` suffixes served through the same external article route dispatcher. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. - Handler test stability fixes are committed into the baseline before feature parity work continues. - `.codegraph/` is generated indexing output and is not part of tracked product code. @@ -138,7 +138,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 357-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok or the next evidence-backed route/controller/serializer drift. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 362-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift or Phase 6 placeholder burn-down. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 2 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; fresh `rg` audit shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Continue targeted account/contact/conversation/message/inbox drift from reference/smoke evidence. | `rg` placeholder audit is recorded and no reused-frontend blocker is ownerless. | | 3 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | | 4 | P3.10 account webhooks API | Implemented for the reused dashboard Webhooks settings route with PATCH update, Chatwoot `{ payload }` list/mutation serializers, nested `{ webhook: ... }` bodies, generated secret, account-scoped mutations, URL/subscription validation, optional inbox serialization, and empty `200 OK` delete. | Keep in Review; reopen only if live settings smoke exposes audit writer or delivery-signature drift beyond the existing delivery service boundary. | Focused webhook handler/service/router tests, migration test, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -207,6 +207,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(channels): expose oauth callbacks` | Advances Phase 2/3 root channel callback parity by matching Chatwoot `Twitter::CallbacksController#show`, `Google::CallbacksController#show`, `Microsoft::CallbacksController#show`, `Instagram::CallbacksController#show`, `Tiktok::CallbacksController#show`, and `routes.rb:626/649-652`. GoChat now registers `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`; Google/Microsoft callbacks exchange OAuth codes and create/update email inboxes from ID-token mailbox identity; Instagram/TikTok/Twitter callbacks create/update channel inboxes and preserve Chatwoot success/error redirect paths for reused frontend onboarding/settings flows. | `go test ./internal/router -run 'ChannelCallbacks\|IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 900`; tracked route parity is `353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362`. | Root channel callbacks move to Review for current reference evidence; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(integrations): expose oauth callbacks` | Advances Phase 2/3 root integration callback parity by matching Chatwoot `Linear::CallbacksController#show`, `Shopify::CallbacksController#show`, `Notion::CallbacksController#show`, account Shopify auth behavior, and `routes.rb:630/634/654`. GoChat now registers `/linear/callback`, `/shopify/callback`, and `/notion/callback`; verifies account state, exchanges OAuth codes through configurable provider token endpoints, persists account integration hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify auth `redirect_url` authorization payloads. | `go test ./internal/router -run 'IntegrationCallbacks\|RegisterRoutes' -count=1`; `go test ./internal/service -run ShopifyIntegrationService -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`. Route dump is `TOTAL: 895`; tracked route parity is `348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357`. | Linear/Shopify/Notion root callbacks move to Review for current reference evidence; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. | | `feat(twilio): expose root callback` | Advances Phase 2/3 Twilio ingress parity by matching Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and `routes.rb:639-640`. GoChat now registers the exact `/twilio/callback` route, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound callbacks by `To` or `MessagingServiceSid`, normalizes phone numbers to stored E.164 channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and durable persister boundary. | `go test ./internal/handler/webhook -run 'TwilioCallback\|TwilioWebhook\|TwilioDeliveryStatus' -count=1`; `go test ./internal/router -run 'RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 892`; tracked route parity is `345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354`. | Twilio root callback ingress moves to Review for current reference evidence; continue Phase 2/3 callback route audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(twilio): expose voice callbacks` | Advances Phase 2/3 enterprise voice parity by matching Chatwoot enterprise `Twilio::VoiceController#call_twiml/#status/#conference_status/#recording_status` and `routes.rb:643-646`. GoChat now registers `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; voice callbacks resolve the Twilio phone to a voice-enabled inbox, return conference TwiML for existing call rows, map Twilio call/conference events to persisted call state, and retain status/conference/recording callback payloads in call attributes. | `go test ./internal/router -run 'TwilioVoice\|RegisterRoutes' -count=1`; `go test ./cmd/route_parity -count=1`; `go run ./cmd/dump_routes > docs/parity/gochat_routes.txt`; `go run ./cmd/route_parity`; `go test ./...`; `git diff --check`. Route dump is `TOTAL: 891`; tracked route parity is `343 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 352`. | Enterprise Twilio voice callbacks move to Review for current reference evidence; continue Phase 2/3 drift audit, callback route audit, Phase 6 placeholder audit, or B12 live smoke. | @@ -2357,3 +2358,4 @@ Verification milestone gates: - 2026-06-06: Twilio voice callback checkpoint prepared as `feat(twilio): expose voice callbacks`; audited Chatwoot enterprise `Twilio::VoiceController`, voice callback routes `routes.rb:643-646`, and existing Go contact-call persistence. GoChat now exposes `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`; callbacks resolve `Channel::TwilioSms` phones to voice-enabled inboxes, return conference TwiML for persisted call rows, map Twilio status/conference events to call state, and persist callback payloads/recording metadata. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: Twilio root callback checkpoint prepared as `feat(twilio): expose root callback`; audited Chatwoot `Twilio::CallbackController#create`, `Twilio::DeliveryStatusController#create`, and routes `639-640`. GoChat now exposes `/twilio/callback`, tracks `/twilio/delivery_status`, acknowledges root callbacks with `204 No Content`, resolves inbound events by `To` or `MessagingServiceSid`, normalizes phone numbers to the stored Twilio channel rows, and persists inbound SMS/MMS through the existing Twilio incoming-message pipeline and persister. Focused webhook/router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 callback audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: Root integration callback checkpoint prepared as `feat(integrations): expose oauth callbacks`; audited Chatwoot `Linear::CallbacksController`, `Shopify::CallbacksController`, `Notion::CallbacksController`, Shopify account auth, and routes `630/634/654`. GoChat now exposes `/linear/callback`, `/shopify/callback`, and `/notion/callback`, verifies state, exchanges OAuth codes via configurable token endpoints, persists account hooks with Chatwoot app IDs/access tokens/settings/reference IDs, redirects to reused frontend integration settings, and returns Shopify account auth `redirect_url`. Focused router/service/route-parity tests and route dump/parity regeneration passed; continue the remaining root channel callback audit for Twitter/Google/Microsoft/Instagram/TikTok, Phase 6 placeholder audit, or B12 live smoke. +- 2026-06-06: Root channel callback checkpoint prepared as `feat(channels): expose oauth callbacks`; audited Chatwoot `Twitter::CallbacksController`, `Google::CallbacksController`, `Microsoft::CallbacksController`, `Instagram::CallbacksController`, `Tiktok::CallbacksController`, shared OAuth callback behavior, and routes `626/649-652`. GoChat now exposes `/twitter/callback`, `/google/callback`, `/microsoft/callback`, `/instagram/callback`, and `/tiktok/callback`; callbacks exchange OAuth tokens through configurable provider endpoints, create/update email/social channel inboxes, and redirect successful or failed flows to Chatwoot dashboard inbox agents/settings/new-inbox paths. Focused channel callback/router/route-parity tests and route dump/parity regeneration passed; continue Phase 6 placeholder audit, B12 live smoke, or the next evidence-backed route/controller/serializer drift. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 466b07a8..d82e6d9a 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -420,6 +420,7 @@ GET /app GET /app/*params GET /auth/validate_token GET /cable +GET /google/callback GET /hc/:slug GET /hc/:slug/:locale GET /hc/:slug/:locale/articles @@ -433,7 +434,9 @@ GET /hc/:slug/:locale/search GET /hc/:slug/articles/:article_slug GET /hc/:slug/sitemap.xml GET /health +GET /instagram/callback GET /linear/callback +GET /microsoft/callback GET /notion/callback GET /platform/api/v1/accounts GET /platform/api/v1/accounts/:account_id @@ -463,6 +466,8 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages GET /shopify/callback +GET /tiktok/callback +GET /twitter/callback GET /webhooks/instagram GET /webhooks/tiktok/:business_id GET /webhooks/twitter @@ -893,4 +898,4 @@ PUT /public/api/v1/csat_survey/:id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /widget/direct_uploads/:upload_uuid -TOTAL: 895 +TOTAL: 900 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 214754d8..0b1716ea 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 348 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 357 tracked critical routes. +Summary: 353 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 362 tracked critical routes. ## Missing Critical Routes @@ -215,6 +215,7 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/api/v2/accounts/:account_id/summary_reports/team` | `/api/v2/accounts/:account_id/summary_reports/team` | `api/v2/accounts/summary_reports#team` | `routes.rb:482` | exact | | GET | `/app` | `/app` | `dashboard#index` | `routes.rb:19` | exact | | GET | `/app/*params` | `/app/*params` | `dashboard#index` | `routes.rb:20` | exact | +| GET | `/google/callback` | `/google/callback` | `google/callbacks#show` | `routes.rb:650` | exact | | GET | `/hc/:slug` | `/hc/:slug` | `public/api/v1/portals#show` | `routes.rb:590` | exact | | GET | `/hc/:slug/:locale` | `/hc/:slug/:locale` | `public/api/v1/portals#show` | `routes.rb:592` | exact | | GET | `/hc/:slug/:locale/articles` | `/hc/:slug/:locale/articles` | `public/api/v1/portals/articles#index` | `routes.rb:594` | exact | @@ -227,7 +228,9 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/hc/:slug/:locale/search` | `/hc/:slug/:locale/search` | `public/api/v1/portals/search#index` | `routes.rb:593` | exact | | GET | `/hc/:slug/articles/:article_slug` | `/hc/:slug/articles/:article_slug` | `public/api/v1/portals/articles#show` | `routes.rb:601` | exact | | GET | `/hc/:slug/sitemap.xml` | `/hc/:slug/sitemap.xml` | `public/api/v1/portals#sitemap` | `routes.rb:591` | exact | +| GET | `/instagram/callback` | `/instagram/callback` | `instagram/callbacks#show` | `routes.rb:651` | exact | | GET | `/linear/callback` | `/linear/callback` | `linear/callbacks#show` | `routes.rb:630` | exact | +| GET | `/microsoft/callback` | `/microsoft/callback` | `microsoft/callbacks#show` | `routes.rb:649` | exact | | GET | `/notion/callback` | `/notion/callback` | `notion/callbacks#show` | `routes.rb:654` | exact | | GET | `/public/api/v1/csat_survey/:id` | `/public/api/v1/csat_survey/:id` | `public/api/v1/csat_survey#show` | `routes.rb:585` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id` | `public/api/v1/inboxes/contacts#show` | `routes.rb:572` | exact | @@ -235,6 +238,8 @@ These routes exist with equivalent method and path shape but different parameter | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id` | `public/api/v1/inboxes/conversations#show` | `routes.rb:573` | exact | | GET | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages` | `public/api/v1/inboxes/messages#index` | `routes.rb:580` | exact | | GET | `/shopify/callback` | `/shopify/callback` | `shopify/callbacks#show` | `routes.rb:634` | exact | +| GET | `/tiktok/callback` | `/tiktok/callback` | `tiktok/callbacks#show` | `routes.rb:652` | exact | +| GET | `/twitter/callback` | `/twitter/callback` | `twitter/callbacks#show` | `routes.rb:626` | exact | | PATCH | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `/api/v1/accounts/:account_id/agent_bots/:agent_bot_id` | `api/v1/accounts/agent_bots#update` | `routes.rb:94` | exact | | PATCH | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `/api/v1/accounts/:account_id/agent_capacity_policies/:id` | `api/v1/accounts/agent_capacity_policies#update` | `routes.rb:123` | exact | | PATCH | `/api/v1/accounts/:account_id/inbox_members/` | `/api/v1/accounts/:account_id/inbox_members/` | `api/v1/accounts/inbox_members#update` | `routes.rb:278` | exact | diff --git a/internal/router/channel_callbacks.go b/internal/router/channel_callbacks.go new file mode 100644 index 00000000..fa54d9d9 --- /dev/null +++ b/internal/router/channel_callbacks.go @@ -0,0 +1,460 @@ +package router + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" +) + +func googleEmailCallback(db *gorm.DB) gin.HandlerFunc { + return emailOAuthCallback(db, emailCallbackConfig{ + Provider: "google", + ClientIDEnv: "GOOGLE_OAUTH_CLIENT_ID", + SecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET", + TokenURLEnv: "GOOGLE_OAUTH_TOKEN_URL", + DefaultURL: "https://oauth2.googleapis.com/token", + IMAPAddress: "imap.gmail.com", + StateSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET", + }) +} + +func microsoftEmailCallback(db *gorm.DB) gin.HandlerFunc { + return emailOAuthCallback(db, emailCallbackConfig{ + Provider: "microsoft", + ClientIDEnv: "AZURE_APP_ID", + SecretEnv: "AZURE_APP_SECRET", + TokenURLEnv: "MICROSOFT_OAUTH_TOKEN_URL", + DefaultURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + IMAPAddress: "outlook.office365.com", + StateSecretEnv: "AZURE_APP_SECRET", + }) +} + +type emailCallbackConfig struct { + Provider string + ClientIDEnv string + SecretEnv string + TokenURLEnv string + DefaultURL string + IMAPAddress string + StateSecretEnv string +} + +func emailOAuthCallback(db *gorm.DB, cfg emailCallbackConfig) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv(cfg.StateSecretEnv)) + if !ok || db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: envOrDefault(cfg.TokenURLEnv, cfg.DefaultURL), + ClientID: os.Getenv(cfg.ClientIDEnv), + ClientSecret: os.Getenv(cfg.SecretEnv), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/" + cfg.Provider + "/callback", + }) + claims, claimErr := parseJWTClaimsUnverified(body["id_token"]) + if err != nil || claimErr != nil || strings.TrimSpace(claimString(claims, "email")) == "" { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + + email := claimString(claims, "email") + login := email + if cfg.Provider == "microsoft" { + if value := firstNonBlank(claimString(claims, "preferred_username"), claimString(claims, "upn")); value != "" { + login = value + } + } + name := firstNonBlank(claimString(claims, "name"), strings.Split(email, "@")[0]) + inbox, existed, err := upsertEmailOAuthInbox(c, db, accountID, email, login, name, cfg.IMAPAddress, cfg.Provider, body) + if err != nil { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + if existed { + c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) + return + } + c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) + } +} + +func instagramChannelCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("INSTAGRAM_APP_SECRET")) + if !ok { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + if c.Query("error") != "" { + c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{ + "error_type": firstNonBlank(c.Query("error"), "authorization_error"), + "code": "400", + "error_message": firstNonBlank(c.Query("error_description"), "Authorization was denied"), + })) + return + } + if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", nil)) + return + } + + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: envOrDefault("INSTAGRAM_OAUTH_TOKEN_URL", "https://api.instagram.com/oauth/access_token"), + ClientID: os.Getenv("INSTAGRAM_APP_ID"), + ClientSecret: os.Getenv("INSTAGRAM_APP_SECRET"), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/instagram/callback", + }) + instagramID := firstNonBlank(body["instagram_account_id"], body["user_id"], body["id"]) + username := firstNonBlank(body["username"], body["instagram_account_name"], "Instagram") + if err != nil || body["access_token"] == "" || instagramID == "" { + c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "OAuthException", "code": "400", "error_message": "failed to exchange OAuth token"})) + return + } + + inbox, existed, err := upsertInstagramInbox(c, db, accountID, instagramID, username, body) + if err != nil { + c.Redirect(http.StatusFound, newInboxURL(accountID, "instagram", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()})) + return + } + if existed { + c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) + return + } + c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) + } +} + +func tiktokChannelCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(c.Query("state"), os.Getenv("TIKTOK_APP_SECRET")) + if !ok { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + if c.Query("error") != "" { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{ + "error_type": firstNonBlank(c.Query("error"), "access_denied"), + "code": c.Query("error_code"), + "error_message": firstNonBlank(c.Query("error_description"), "User cancelled the Authorization"), + })) + return + } + if db == nil || c.Query("code") == "" || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", nil)) + return + } + + body, err := exchangeOAuthToken(c, oauthTokenExchangeRequest{ + TokenURL: envOrDefault("TIKTOK_OAUTH_TOKEN_URL", "https://business-api.tiktok.com/open_api/v1.3/oauth2/access_token/"), + ClientID: os.Getenv("TIKTOK_APP_ID"), + ClientSecret: os.Getenv("TIKTOK_APP_SECRET"), + Code: c.Query("code"), + RedirectURI: frontendBaseURL() + "/tiktok/callback", + }) + businessID := firstNonBlank(body["business_id"], body["tiktok_business_id"], body["advertiser_id"]) + if err != nil || body["access_token"] == "" || businessID == "" { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "OAuthException", "code": "500", "error_message": "failed to exchange OAuth token"})) + return + } + if !tiktokScopesGranted(body["scope"]) { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "ungranted_scopes", "code": "400", "error_message": "User did not grant all the required scopes"})) + return + } + + name := firstNonBlank(body["display_name"], body["username"], "TikTok") + inbox, existed, err := upsertTikTokInbox(c, db, accountID, businessID, name, body) + if err != nil { + c.Redirect(http.StatusFound, newInboxURL(accountID, "tiktok", map[string]string{"error_type": "RecordInvalid", "code": "500", "error_message": err.Error()})) + return + } + if existed { + c.Redirect(http.StatusFound, inboxSettingsURL(accountID, inbox.ID)) + return + } + c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) + } +} + +func twitterChannelCallback(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + accountID, ok := callbackAccountID(firstNonBlank(c.Query("state"), c.Query("account_id")), os.Getenv("TWITTER_CONSUMER_SECRET")) + if !ok { + c.Redirect(http.StatusFound, frontendBaseURL()) + return + } + if c.Query("denied") != "" || db == nil || !accountExists(c, db, accountID) { + c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) + return + } + body, err := exchangeTwitterAccessToken(c) + if err != nil || body.Get("oauth_token") == "" || body.Get("user_id") == "" { + c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) + return + } + inbox, _, err := upsertTwitterInbox(c, db, accountID, body) + if err != nil { + c.Redirect(http.StatusFound, newInboxURL(accountID, "twitter", nil)) + return + } + c.Redirect(http.StatusFound, inboxAgentsURL(accountID, inbox.ID)) + } +} + +func upsertEmailOAuthInbox(c *gin.Context, db *gorm.DB, accountID uint, email, login, name, imapAddress, provider string, tokenBody map[string]string) (*model.Inbox, bool, error) { + var channel channelmodel.ChannelEmail + existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND (imap_login = ? OR email = ?)", accountID, login, email).First(&channel).Error == nil + if existed { + channel.IMAPLogin = login + channel.IMAPAddress = imapAddress + channel.IMAPPort = 993 + channel.IMAPEnabled = true + channel.MailboxName = name + if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { + return nil, false, err + } + var inbox model.Inbox + if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { + return nil, false, err + } + inbox.Name = name + inbox.ChannelConfig = oauthInboxConfig(provider, tokenBody) + return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error + } + + channel = channelmodel.ChannelEmail{AccountID: accountID, Email: email, MailboxName: name, Domain: emailDomain(email), IMAPEnabled: true, IMAPAddress: imapAddress, IMAPPort: 993, IMAPLogin: login, IMAPSSLMode: "ssl"} + if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { + return nil, false, err + } + inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "email", ChannelID: channel.ID, Enabled: true, ChannelConfig: oauthInboxConfig(provider, tokenBody)} + if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { + return nil, false, err + } + channel.InboxID = inbox.ID + return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error +} + +func upsertInstagramInbox(c *gin.Context, db *gorm.DB, accountID uint, instagramID, username string, body map[string]string) (*model.Inbox, bool, error) { + var channel channelmodel.ChannelInstagram + existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND instagram_account_id = ?", accountID, instagramID).First(&channel).Error == nil + if existed { + channel.PageAccessToken = body["access_token"] + channel.InstagramAccountName = username + if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { + return nil, false, err + } + var inbox model.Inbox + if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { + return nil, false, err + } + inbox.Name = username + return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error + } + channel = channelmodel.ChannelInstagram{AccountID: accountID, InstagramAccountID: instagramID, InstagramBusinessAccountID: body["instagram_business_account_id"], PageAccessToken: body["access_token"], ConnectedFBPageID: firstNonBlank(body["connected_fb_page_id"], body["page_id"]), InstagramAccountName: username} + if channel.ConnectedFBPageID == "" { + channel.ConnectedFBPageID = instagramID + } + if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { + return nil, false, err + } + inbox := model.Inbox{AccountID: accountID, Name: username, ChannelType: "instagram", ChannelID: channel.ID, Enabled: true} + if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { + return nil, false, err + } + channel.InboxID = inbox.ID + return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error +} + +func upsertTikTokInbox(c *gin.Context, db *gorm.DB, accountID uint, businessID, name string, body map[string]string) (*model.Inbox, bool, error) { + var channel channelmodel.ChannelTikTok + existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND tiktok_business_id = ?", accountID, businessID).First(&channel).Error == nil + expiresAt := time.Now().UTC().Add(time.Duration(parseIntDefault(body["expires_in"], 3600)) * time.Second) + if existed { + channel.AccessToken = body["access_token"] + channel.RefreshToken = body["refresh_token"] + channel.TokenExpiresAt = expiresAt + if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { + return nil, false, err + } + var inbox model.Inbox + if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { + return nil, false, err + } + inbox.Name = name + return &inbox, true, db.WithContext(c.Request.Context()).Save(&inbox).Error + } + channel = channelmodel.ChannelTikTok{AccountID: accountID, TikTokBusinessID: businessID, AccessToken: body["access_token"], RefreshToken: body["refresh_token"], TokenExpiresAt: expiresAt} + if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { + return nil, false, err + } + inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "tiktok", ChannelID: channel.ID, Enabled: true} + if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { + return nil, false, err + } + channel.InboxID = inbox.ID + return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error +} + +func upsertTwitterInbox(c *gin.Context, db *gorm.DB, accountID uint, values url.Values) (*model.Inbox, bool, error) { + var channel channelmodel.ChannelTwitter + existed := db.WithContext(c.Request.Context()).Where("account_id = ? AND twitter_user_id = ?", accountID, values.Get("user_id")).First(&channel).Error == nil + name := firstNonBlank(values.Get("screen_name"), values.Get("name"), "Twitter") + if existed { + channel.TwitterAccessToken = values.Get("oauth_token") + channel.TwitterAccessTokenSecret = values.Get("oauth_token_secret") + channel.ScreenName = values.Get("screen_name") + channel.Name = name + if err := db.WithContext(c.Request.Context()).Save(&channel).Error; err != nil { + return nil, false, err + } + var inbox model.Inbox + if err := db.WithContext(c.Request.Context()).Where("id = ? AND account_id = ?", channel.InboxID, accountID).First(&inbox).Error; err != nil { + return nil, false, err + } + return &inbox, true, nil + } + channel = channelmodel.ChannelTwitter{AccountID: accountID, TwitterUserID: values.Get("user_id"), TwitterAccessToken: values.Get("oauth_token"), TwitterAccessTokenSecret: values.Get("oauth_token_secret"), ScreenName: values.Get("screen_name"), Name: name, AccessToken: values.Get("oauth_token")} + if err := db.WithContext(c.Request.Context()).Create(&channel).Error; err != nil { + return nil, false, err + } + inbox := model.Inbox{AccountID: accountID, Name: name, ChannelType: "twitter", ChannelID: channel.ID, Enabled: true} + if err := db.WithContext(c.Request.Context()).Create(&inbox).Error; err != nil { + return nil, false, err + } + channel.InboxID = inbox.ID + return &inbox, false, db.WithContext(c.Request.Context()).Save(&channel).Error +} + +func parseJWTClaimsUnverified(tokenString string) (jwt.MapClaims, error) { + claims := jwt.MapClaims{} + _, _, err := jwt.NewParser().ParseUnverified(tokenString, claims) + return claims, err +} + +func claimString(claims jwt.MapClaims, key string) string { + if value, ok := claims[key].(string); ok { + return value + } + return "" +} + +func oauthInboxConfig(provider string, tokenBody map[string]string) string { + encoded, _ := json.Marshal(map[string]any{"provider": provider, "provider_config": compactStringMap(tokenBody)}) + return string(encoded) +} + +func compactStringMap(values map[string]string) map[string]string { + result := make(map[string]string, len(values)) + for key, value := range values { + if value != "" { + result[key] = value + } + } + return result +} + +func exchangeTwitterAccessToken(c *gin.Context) (url.Values, error) { + form := url.Values{} + form.Set("oauth_token", c.Query("oauth_token")) + form.Set("oauth_verifier", c.Query("oauth_verifier")) + req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, envOrDefault("TWITTER_OAUTH_TOKEN_URL", "https://api.twitter.com/oauth/access_token"), strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("twitter access token failed: %s", resp.Status) + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + return url.ParseQuery(string(body)) +} + +func tiktokScopesGranted(scope string) bool { + if strings.TrimSpace(scope) == "" { + return true + } + granted := map[string]bool{} + for _, item := range strings.Split(scope, ",") { + granted[strings.TrimSpace(item)] = true + } + for _, required := range []string{"user.info.basic", "user.info.username", "user.info.stats", "user.info.profile", "user.account.type", "user.insights", "message.list.read", "message.list.send", "message.list.manage"} { + if !granted[required] { + return false + } + } + return true +} + +func inboxAgentsURL(accountID uint, inboxID uint) string { + return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%d/agents", frontendBaseURL(), accountID, inboxID) +} + +func inboxSettingsURL(accountID uint, inboxID uint) string { + return fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/%d", frontendBaseURL(), accountID, inboxID) +} + +func newInboxURL(accountID uint, channel string, query map[string]string) string { + base := fmt.Sprintf("%s/app/accounts/%d/settings/inboxes/new/%s", frontendBaseURL(), accountID, channel) + if len(query) == 0 { + return base + } + values := url.Values{} + for key, value := range query { + if value != "" { + values.Set(key, value) + } + } + if len(values) == 0 { + return base + } + return base + "?" + values.Encode() +} + +func emailDomain(email string) string { + parts := strings.SplitN(email, "@", 2) + if len(parts) == 2 { + return parts[1] + } + return "" +} + +func parseIntDefault(value string, fallback int) int { + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/router/router.go b/internal/router/router.go index b8349224..15df0af0 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -212,6 +212,11 @@ func RegisterRoutes( engine.GET("/linear/callback", linearIntegrationCallback(db)) engine.GET("/shopify/callback", shopifyIntegrationCallback(db)) engine.GET("/notion/callback", notionIntegrationCallback(db)) + engine.GET("/twitter/callback", twitterChannelCallback(db)) + engine.GET("/google/callback", googleEmailCallback(db)) + engine.GET("/microsoft/callback", microsoftEmailCallback(db)) + engine.GET("/instagram/callback", instagramChannelCallback(db)) + engine.GET("/tiktok/callback", tiktokChannelCallback(db)) // Dashboard shell routes used by Chatwoot mailer and push deep links. // Reference: Chatwoot routes.rb `get '/app'`, `get '/app/*params'` -> DashboardController#index. diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 532891cf..d599976b 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -51,6 +51,11 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "GET /linear/callback", "GET /shopify/callback", "GET /notion/callback", + "GET /twitter/callback", + "GET /google/callback", + "GET /microsoft/callback", + "GET /instagram/callback", + "GET /tiktok/callback", "GET /app", "GET /app/*params", "GET /api/v1/accounts/:account_id/captain/assistants/tools", @@ -312,6 +317,124 @@ func TestIntegrationCallbacksRedirectSafelyOnInvalidState(t *testing.T) { } } +func TestChannelCallbacksCreateInboxesAndRedirect(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("GOOGLE_OAUTH_CLIENT_ID", "google-client") + t.Setenv("GOOGLE_OAUTH_CLIENT_SECRET", "google-secret") + t.Setenv("AZURE_APP_ID", "microsoft-client") + t.Setenv("AZURE_APP_SECRET", "microsoft-secret") + t.Setenv("INSTAGRAM_APP_ID", "instagram-client") + t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret") + t.Setenv("TIKTOK_APP_ID", "tiktok-client") + t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret") + t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret") + t.Setenv("GOOGLE_OAUTH_TOKEN_URL", "https://oauth.example.test/google") + t.Setenv("MICROSOFT_OAUTH_TOKEN_URL", "https://oauth.example.test/microsoft") + t.Setenv("INSTAGRAM_OAUTH_TOKEN_URL", "https://oauth.example.test/instagram") + t.Setenv("TIKTOK_OAUTH_TOKEN_URL", "https://oauth.example.test/tiktok") + t.Setenv("TWITTER_OAUTH_TOKEN_URL", "https://oauth.example.test/twitter") + + withFakeCallbackTransport(t, map[string]string{ + "/google": jsonOAuthBody(t, map[string]any{"access_token": "google-token", "refresh_token": "google-refresh", "id_token": idToken(t, map[string]any{"email": "gmail@example.com", "name": "Gmail Support"})}), + "/microsoft": jsonOAuthBody(t, map[string]any{"access_token": "microsoft-token", "refresh_token": "microsoft-refresh", "id_token": idToken(t, map[string]any{"email": "outlook@example.com", "preferred_username": "agent@contoso.com", "name": "Outlook Support"})}), + "/instagram": jsonOAuthBody(t, map[string]any{"access_token": "instagram-token", "instagram_account_id": "ig-1", "username": "insta_support", "connected_fb_page_id": "page-1"}), + "/tiktok": jsonOAuthBody(t, map[string]any{"access_token": "tiktok-token", "refresh_token": "tiktok-refresh", "business_id": "biz-1", "display_name": "TikTok Support", "expires_in": 3600, "scope": "user.info.basic,user.info.username,user.info.stats,user.info.profile,user.account.type,user.insights,message.list.read,message.list.send,message.list.manage"}), + "/twitter": "oauth_token=twitter-token&oauth_token_secret=twitter-secret&user_id=tw-1&screen_name=tw_support", + }) + + db, account := setupRouterChannelCallbackDB(t) + engine := gin.New() + engine.GET("/google/callback", googleEmailCallback(db)) + engine.GET("/microsoft/callback", microsoftEmailCallback(db)) + engine.GET("/instagram/callback", instagramChannelCallback(db)) + engine.GET("/tiktok/callback", tiktokChannelCallback(db)) + engine.GET("/twitter/callback", twitterChannelCallback(db)) + + checks := []struct { + path string + secret string + }{ + {"/google/callback?code=google-code", "google-secret"}, + {"/microsoft/callback?code=microsoft-code", "microsoft-secret"}, + {"/instagram/callback?code=instagram-code", "instagram-secret"}, + {"/tiktok/callback?code=tiktok-code", "tiktok-secret"}, + {"/twitter/callback?oauth_token=request-token&oauth_verifier=verifier", "twitter-secret"}, + } + for _, check := range checks { + state := signedCallbackState(t, account.ID, check.secret) + separator := "&" + if !strings.Contains(check.path, "?") { + separator = "?" + } + resp := performGet(engine, check.path+separator+"state="+url.QueryEscape(state)) + if resp.Code != http.StatusFound || !strings.Contains(resp.Header().Get("Location"), "/settings/inboxes/new/") { + t.Fatalf("expected new inbox agents redirect for %s, got %d %q", check.path, resp.Code, resp.Header().Get("Location")) + } + } + + var inboxes []model.Inbox + if err := db.Order("id ASC").Find(&inboxes).Error; err != nil { + t.Fatalf("failed to load channel callback inboxes: %v", err) + } + if len(inboxes) != 5 { + t.Fatalf("expected five callback-created inboxes, got %d", len(inboxes)) + } + expectedTypes := []string{"email", "email", "instagram", "tiktok", "twitter"} + for i, expected := range expectedTypes { + if inboxes[i].ChannelType != expected { + t.Fatalf("expected inbox %d channel type %s, got %s", i, expected, inboxes[i].ChannelType) + } + } + + var email channelmodel.ChannelEmail + if err := db.Where("email = ?", "gmail@example.com").First(&email).Error; err != nil || email.IMAPAddress != "imap.gmail.com" || !email.IMAPEnabled { + t.Fatalf("expected google email channel with IMAP config, channel=%+v err=%v", email, err) + } + var microsoft channelmodel.ChannelEmail + if err := db.Where("email = ?", "outlook@example.com").First(µsoft).Error; err != nil || microsoft.IMAPLogin != "agent@contoso.com" || microsoft.IMAPAddress != "outlook.office365.com" { + t.Fatalf("expected microsoft email channel with login, channel=%+v err=%v", microsoft, err) + } + var instagram channelmodel.ChannelInstagram + if err := db.Where("instagram_account_id = ?", "ig-1").First(&instagram).Error; err != nil || instagram.PageAccessToken != "instagram-token" { + t.Fatalf("expected instagram channel, channel=%+v err=%v", instagram, err) + } + var tiktok channelmodel.ChannelTikTok + if err := db.Where("tiktok_business_id = ?", "biz-1").First(&tiktok).Error; err != nil || tiktok.AccessToken != "tiktok-token" { + t.Fatalf("expected tiktok channel, channel=%+v err=%v", tiktok, err) + } + var twitter channelmodel.ChannelTwitter + if err := db.Where("twitter_user_id = ?", "tw-1").First(&twitter).Error; err != nil || twitter.TwitterAccessToken != "twitter-token" { + t.Fatalf("expected twitter channel, channel=%+v err=%v", twitter, err) + } +} + +func TestChannelCallbacksRedirectErrorsToNewInbox(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("FRONTEND_URL", "https://app.example.test") + t.Setenv("INSTAGRAM_APP_SECRET", "instagram-secret") + t.Setenv("TIKTOK_APP_SECRET", "tiktok-secret") + t.Setenv("TWITTER_CONSUMER_SECRET", "twitter-secret") + db, account := setupRouterChannelCallbackDB(t) + engine := gin.New() + engine.GET("/instagram/callback", instagramChannelCallback(db)) + engine.GET("/tiktok/callback", tiktokChannelCallback(db)) + engine.GET("/twitter/callback", twitterChannelCallback(db)) + + instagram := performGet(engine, "/instagram/callback?error=access_denied&error_description=nope&state="+url.QueryEscape(signedCallbackState(t, account.ID, "instagram-secret"))) + if instagram.Code != http.StatusFound || !strings.Contains(instagram.Header().Get("Location"), "/settings/inboxes/new/instagram") || !strings.Contains(instagram.Header().Get("Location"), "error_message=nope") { + t.Fatalf("expected instagram error redirect, got %d %q", instagram.Code, instagram.Header().Get("Location")) + } + tiktok := performGet(engine, "/tiktok/callback?error=access_denied&error_description=nope&state="+url.QueryEscape(signedCallbackState(t, account.ID, "tiktok-secret"))) + if tiktok.Code != http.StatusFound || !strings.Contains(tiktok.Header().Get("Location"), "/settings/inboxes/new/tiktok") || !strings.Contains(tiktok.Header().Get("Location"), "error_message=nope") { + t.Fatalf("expected tiktok error redirect, got %d %q", tiktok.Code, tiktok.Header().Get("Location")) + } + twitter := performGet(engine, "/twitter/callback?denied=true&state="+url.QueryEscape(signedCallbackState(t, account.ID, "twitter-secret"))) + if twitter.Code != http.StatusFound || twitter.Header().Get("Location") != "https://app.example.test/app/accounts/1/settings/inboxes/new/twitter" { + t.Fatalf("expected twitter denied redirect, got %d %q", twitter.Code, twitter.Header().Get("Location")) + } +} + func TestWellKnownRoutesServeMobileAssociationPayloads(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("ANDROID_BUNDLE_ID", "com.example.gochat") @@ -455,6 +578,31 @@ func setupRouterIntegrationCallbackDB(t *testing.T) (*gorm.DB, *model.Account) { return db, account } +func setupRouterChannelCallbackDB(t *testing.T) (*gorm.DB, *model.Account) { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.Account{}, + &model.Inbox{}, + &channelmodel.ChannelEmail{}, + &channelmodel.ChannelInstagram{}, + &channelmodel.ChannelTikTok{}, + &channelmodel.ChannelTwitter{}, + ); err != nil { + t.Fatalf("failed to migrate channel callback models: %v", err) + } + account := &model.Account{Name: "Channel Callback Account", Locale: "en"} + if err := db.Create(account).Error; err != nil { + t.Fatalf("failed to create account: %v", err) + } + return db, account +} + func signedCallbackState(t *testing.T, accountID uint, secret string) string { t.Helper() token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ @@ -505,6 +653,46 @@ func withFakeOAuthTransport(t *testing.T, payloads map[string]map[string]any) { t.Cleanup(func() { http.DefaultClient = original }) } +func withFakeCallbackTransport(t *testing.T, bodies map[string]string) { + t.Helper() + original := http.DefaultClient + http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Method != http.MethodPost { + t.Fatalf("expected callback token exchange POST, got %s", req.Method) + } + body, ok := bodies[req.URL.Path] + if !ok { + t.Fatalf("unexpected callback token URL path: %s", req.URL.Path) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })} + t.Cleanup(func() { http.DefaultClient = original }) +} + +func jsonOAuthBody(t *testing.T, payload map[string]any) string { + t.Helper() + encoded, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to encode OAuth payload: %v", err) + } + return string(encoded) +} + +func idToken(t *testing.T, claims map[string]any) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(claims)) + signed, err := token.SignedString([]byte("test-id-token-secret")) + if err != nil { + t.Fatalf("failed to sign id token: %v", err) + } + return signed +} + func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) { t.Helper() db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{