feat(channels): expose oauth callbacks

This commit is contained in:
2026-06-06 10:13:35 +08:00
parent 85819489b9
commit c111926f56
7 changed files with 678 additions and 8 deletions
+5
View File
@@ -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"},
+8 -6
View File
@@ -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.
+6 -1
View File
@@ -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
+6 -1
View File
@@ -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 |
+460
View File
@@ -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 ""
}
+5
View File
@@ -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.
+188
View File
@@ -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(&microsoft).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{