feat(twilio): expose voice callbacks

This commit is contained in:
2026-06-06 09:22:23 +08:00
parent c4b92b7a18
commit dc8fc2ad41
6 changed files with 449 additions and 8 deletions
+4
View File
@@ -29,6 +29,10 @@ var criticalRoutes = []route{
{Method: "GET", Path: "/.well-known/apple-app-site-association", Controller: "apple_app#site_association", Source: "routes.rb:658"}, {Method: "GET", Path: "/.well-known/apple-app-site-association", Controller: "apple_app#site_association", Source: "routes.rb:658"},
{Method: "GET", Path: "/.well-known/microsoft-identity-association.json", Controller: "microsoft#identity_association", Source: "routes.rb:659"}, {Method: "GET", Path: "/.well-known/microsoft-identity-association.json", Controller: "microsoft#identity_association", Source: "routes.rb:659"},
{Method: "GET", Path: "/.well-known/cf-custom-hostname-challenge/:id", Controller: "custom_domains#verify", Source: "routes.rb:660"}, {Method: "GET", Path: "/.well-known/cf-custom-hostname-challenge/:id", Controller: "custom_domains#verify", Source: "routes.rb:660"},
{Method: "POST", Path: "/twilio/voice/call/:phone", Controller: "twilio/voice#call_twiml", Source: "routes.rb:643"},
{Method: "POST", Path: "/twilio/voice/status/:phone", Controller: "twilio/voice#status", Source: "routes.rb:644"},
{Method: "POST", Path: "/twilio/voice/conference_status/:phone", Controller: "twilio/voice#conference_status", Source: "routes.rb:645"},
{Method: "POST", Path: "/twilio/voice/recording_status/:phone", Controller: "twilio/voice#recording_status", Source: "routes.rb:646"},
{Method: "POST", Path: "/api/v1/accounts/", Controller: "api/v1/accounts#create", Source: "routes.rb:47"}, {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"}, {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 Baseline
- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(custom-domains): expose cloudflare challenge`. - Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(twilio): expose voice callbacks`.
- Latest implementation checkpoint: this checkpoint, prepared as `feat(custom-domains): expose cloudflare challenge`. - Latest implementation checkpoint: this checkpoint, prepared as `feat(twilio): expose voice callbacks`.
- Latest documentation/tooling checkpoint: `docs: land full parity tracking plan`; this document is now the active follow-up plan and supersedes `.hermes/plans/*`. - 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`. GoChat registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, `/.well-known/microsoft-identity-association.json`, and `/.well-known/cf-custom-hostname-challenge/:id`; the app association payloads use `ANDROID_BUNDLE_ID`, `ANDROID_SHA256_CERT_FINGERPRINT`, `IOS_APP_ID`, and `AZURE_APP_ID`, while the Cloudflare challenge route resolves `request.host` to `portals.custom_domain` and returns `ssl_settings.cf_verification_body` only when `cf_verification_id` matches. 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 another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence. - 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`; enterprise Twilio voice callback route drift is implemented for `Twilio::VoiceController` and `routes.rb:643-646`. 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`, plus `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`. Twilio voice callbacks resolve the `Channel::TwilioSms` phone/inbox, enforce `voice_enabled`, return conference TwiML, and persist status/conference/recording payloads against stored Twilio call rows. 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 another Phase 6 placeholder burn-down item or a new Phase 2/3 drift slice from fresh reference/smoke evidence.
- `go test ./...` passes. - `go test ./...` passes.
- Route dump succeeds with `TOTAL: 887` after adding the `.well-known` Cloudflare custom hostname challenge route. - Route dump succeeds with `TOTAL: 891` after adding the enterprise Twilio voice callback routes.
- Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`.
- Tracked frontend-critical route audit covers 348 Chatwoot routes: 339 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 352 Chatwoot routes: 343 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. - `/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. - 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. - `.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 | | Priority | Workstream | Current state | Next checkpoint | Commit close rule |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 348-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`, 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, 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. | Run the next evidence-backed route/controller/serializer drift audit. | 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 352-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`, enterprise Twilio voice routes from `routes.rb:643-646`, 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, enterprise Twilio voice 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. | Run the next evidence-backed route/controller/serializer drift audit. | 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. | | 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. | | 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. | | 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 | | Commit | Scope | Verification summary | Follow-up state |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `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. |
| `feat(custom-domains): expose cloudflare challenge` | Advances Phase 2/3 public custom-domain parity by matching Chatwoot enterprise `CustomDomainsController#verify`, Cloudflare SSL setting keys from `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now registers `/.well-known/cf-custom-hostname-challenge/:id`, resolves the request host against `portals.custom_domain`, returns `Domain not found` for unknown domains, returns `Challenge ID not found` for mismatched `ssl_settings.cf_verification_id`, and serves the stored `cf_verification_body` as plain text on success. | `go test ./internal/router -run 'CustomDomain\|WellKnown\|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: 887`; tracked route parity is `339 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 348`. | Cloudflare custom hostname verification moves to Review for current reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(custom-domains): expose cloudflare challenge` | Advances Phase 2/3 public custom-domain parity by matching Chatwoot enterprise `CustomDomainsController#verify`, Cloudflare SSL setting keys from `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now registers `/.well-known/cf-custom-hostname-challenge/:id`, resolves the request host against `portals.custom_domain`, returns `Domain not found` for unknown domains, returns `Challenge ID not found` for mismatched `ssl_settings.cf_verification_id`, and serves the stored `cf_verification_body` as plain text on success. | `go test ./internal/router -run 'CustomDomain\|WellKnown\|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: 887`; tracked route parity is `339 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 348`. | Cloudflare custom hostname verification moves to Review for current reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(well-known): expose app association files` | Advances Phase 2/3 public route parity by matching Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `assetlinks.json.erb`, `site_association.html.erb`, `identity_association.json.jbuilder`, and `routes.rb:657-659`. GoChat now registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, and `/.well-known/microsoft-identity-association.json`; Android payloads use `ANDROID_BUNDLE_ID` and `ANDROID_SHA256_CERT_FINGERPRINT`, Apple payloads use `IOS_APP_ID` plus `/app/accounts/*/conversations/*`, and Microsoft payloads use `AZURE_APP_ID`. | `go test ./internal/router -run 'WellKnown\|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: 886`; tracked route parity is `338 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 347`. | `.well-known` association routes move to Review for current reference/spec evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(well-known): expose app association files` | Advances Phase 2/3 public route parity by matching Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, `assetlinks.json.erb`, `site_association.html.erb`, `identity_association.json.jbuilder`, and `routes.rb:657-659`. GoChat now registers `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, and `/.well-known/microsoft-identity-association.json`; Android payloads use `ANDROID_BUNDLE_ID` and `ANDROID_SHA256_CERT_FINGERPRINT`, Apple payloads use `IOS_APP_ID` plus `/app/accounts/*/conversations/*`, and Microsoft payloads use `AZURE_APP_ID`. | `go test ./internal/router -run 'WellKnown\|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: 886`; tracked route parity is `338 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 347`. | `.well-known` association routes move to Review for current reference/spec evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
| `feat(dashboard): expose app shell routes` | Advances Phase 2/3 dashboard route parity by matching Chatwoot `DashboardController#index`, `routes.rb:19-20`, `layouts/vueapp.html.erb`, and mailer/push helper paths that resolve to `/app/accounts/:account_id/conversations/:display_id`. GoChat now registers `/app` and `/app/*params`, returns a Chatwoot-style HTML dashboard shell with `window.chatwootConfig`, `window.globalConfig`, and the `#app` mount for browser requests, and returns Chatwoot's `406 { error: "Please use API routes instead of dashboard routes for JSON requests" }` for JSON dashboard requests. | `go test ./internal/router -run 'DashboardIndex\|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: 883`; tracked route parity is `335 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 344`. | Dashboard deep-link routes move to Review for current mailer/push evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. | | `feat(dashboard): expose app shell routes` | Advances Phase 2/3 dashboard route parity by matching Chatwoot `DashboardController#index`, `routes.rb:19-20`, `layouts/vueapp.html.erb`, and mailer/push helper paths that resolve to `/app/accounts/:account_id/conversations/:display_id`. GoChat now registers `/app` and `/app/*params`, returns a Chatwoot-style HTML dashboard shell with `window.chatwootConfig`, `window.globalConfig`, and the `#app` mount for browser requests, and returns Chatwoot's `406 { error: "Please use API routes instead of dashboard routes for JSON requests" }` for JSON dashboard requests. | `go test ./internal/router -run 'DashboardIndex\|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: 883`; tracked route parity is `335 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 344`. | Dashboard deep-link routes move to Review for current mailer/push evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. |
@@ -2351,3 +2352,4 @@ Verification milestone gates:
- 2026-06-06: Dashboard app-shell checkpoint prepared as `feat(dashboard): expose app shell routes`; audited Chatwoot `DashboardController#index`, `routes.rb:19-20`, `layouts/vueapp.html.erb`, and mailer/push helper paths that point to `/app/accounts/:account_id/conversations/:display_id`. GoChat now exposes `/app` and `/app/*params`, serves a dashboard HTML shell with `window.chatwootConfig`, `window.globalConfig`, and the `#app` mount, rejects JSON dashboard requests with Chatwoot's `406` error, and tracks the two app shell routes in route parity. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: Dashboard app-shell checkpoint prepared as `feat(dashboard): expose app shell routes`; audited Chatwoot `DashboardController#index`, `routes.rb:19-20`, `layouts/vueapp.html.erb`, and mailer/push helper paths that point to `/app/accounts/:account_id/conversations/:display_id`. GoChat now exposes `/app` and `/app/*params`, serves a dashboard HTML shell with `window.chatwootConfig`, `window.globalConfig`, and the `#app` mount, rejects JSON dashboard requests with Chatwoot's `406` error, and tracks the two app shell routes in route parity. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
- 2026-06-06: `.well-known` app association checkpoint prepared as `feat(well-known): expose app association files`; audited Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, their views/specs, and `routes.rb:657-659`. GoChat now exposes `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, and `/.well-known/microsoft-identity-association.json` with env-backed Android package/fingerprint, iOS app ID, app conversation deep-link path, and Azure application ID payloads. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: `.well-known` app association checkpoint prepared as `feat(well-known): expose app association files`; audited Chatwoot `AndroidAppController#assetlinks`, `AppleAppController#site_association`, `MicrosoftController#identity_association`, their views/specs, and `routes.rb:657-659`. GoChat now exposes `/.well-known/assetlinks.json`, `/.well-known/apple-app-site-association`, and `/.well-known/microsoft-identity-association.json` with env-backed Android package/fingerprint, iOS app ID, app conversation deep-link path, and Azure application ID payloads. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
- 2026-06-06: Cloudflare custom-domain checkpoint prepared as `feat(custom-domains): expose cloudflare challenge`; audited Chatwoot enterprise `CustomDomainsController#verify`, `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now exposes `/.well-known/cf-custom-hostname-challenge/:id`, resolves `request.host` against `portals.custom_domain`, checks `ssl_settings.cf_verification_id`, returns Chatwoot plain-text 404 bodies for unknown domains and mismatched challenges, and serves `ssl_settings.cf_verification_body` on success. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. - 2026-06-06: Cloudflare custom-domain checkpoint prepared as `feat(custom-domains): expose cloudflare challenge`; audited Chatwoot enterprise `CustomDomainsController#verify`, `Cloudflare::BaseCloudflareZoneService`, and `routes.rb:660`. GoChat now exposes `/.well-known/cf-custom-hostname-challenge/:id`, resolves `request.host` against `portals.custom_domain`, checks `ssl_settings.cf_verification_id`, returns Chatwoot plain-text 404 bodies for unknown domains and mismatched challenges, and serves `ssl_settings.cf_verification_body` on success. Focused router tests, route-parity tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. Continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke.
- 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.
+5 -1
View File
@@ -788,6 +788,10 @@ POST /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conver
POST /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing POST /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing
POST /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen POST /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen
POST /twilio/delivery_status POST /twilio/delivery_status
POST /twilio/voice/call/:phone
POST /twilio/voice/conference_status/:phone
POST /twilio/voice/recording_status/:phone
POST /twilio/voice/status/:phone
POST /webhooks/instagram POST /webhooks/instagram
POST /webhooks/line/:line_channel_id POST /webhooks/line/:line_channel_id
POST /webhooks/shopify POST /webhooks/shopify
@@ -885,4 +889,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
PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id
PUT /widget/direct_uploads/:upload_uuid PUT /widget/direct_uploads/:upload_uuid
TOTAL: 887 TOTAL: 891
+5 -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`. 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: 339 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 348 tracked critical routes. Summary: 343 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 352 tracked critical routes.
## Missing Critical Routes ## Missing Critical Routes
@@ -347,6 +347,10 @@ These routes exist with equivalent method and path shape but different parameter
| POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_status` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_status` | `public/api/v1/inboxes/conversations#toggle_status` | `routes.rb:575` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_status` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_status` | `public/api/v1/inboxes/conversations#toggle_status` | `routes.rb:575` | exact |
| POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `public/api/v1/inboxes/conversations#toggle_typing` | `routes.rb:576` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/toggle_typing` | `public/api/v1/inboxes/conversations#toggle_typing` | `routes.rb:576` | exact |
| POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `public/api/v1/inboxes/conversations#update_last_seen` | `routes.rb:577` | exact | | POST | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `/public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/update_last_seen` | `public/api/v1/inboxes/conversations#update_last_seen` | `routes.rb:577` | exact |
| POST | `/twilio/voice/call/:phone` | `/twilio/voice/call/:phone` | `twilio/voice#call_twiml` | `routes.rb:643` | exact |
| POST | `/twilio/voice/conference_status/:phone` | `/twilio/voice/conference_status/:phone` | `twilio/voice#conference_status` | `routes.rb:645` | exact |
| POST | `/twilio/voice/recording_status/:phone` | `/twilio/voice/recording_status/:phone` | `twilio/voice#recording_status` | `routes.rb:646` | exact |
| POST | `/twilio/voice/status/:phone` | `/twilio/voice/status/:phone` | `twilio/voice#status` | `routes.rb:644` | exact |
| PUT | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#update` | `routes.rb:47` | exact | | PUT | `/api/v1/accounts/:account_id` | `/api/v1/accounts/:account_id` | `api/v1/accounts#update` | `routes.rb:47` | exact |
| PUT | `/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 | | PUT | `/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 |
| PUT | `/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 | | PUT | `/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 |
+278
View File
@@ -3,10 +3,12 @@ package router
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"html" "html"
"net" "net"
"net/http" "net/http"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -24,6 +26,7 @@ import (
ws "github.com/gochat/gochat/internal/handler/ws" ws "github.com/gochat/gochat/internal/handler/ws"
"github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
wspkg "github.com/gochat/gochat/internal/ws" wspkg "github.com/gochat/gochat/internal/ws"
"gorm.io/gorm" "gorm.io/gorm"
@@ -448,6 +451,12 @@ func RegisterRoutes(
} }
handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c) handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c)
}) })
// Enterprise Twilio voice callback routes.
// Reference: Chatwoot enterprise Twilio::VoiceController routes.rb:643-646.
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
// Twitter webhook — Account Activity API CRC validation + event processing // Twitter webhook — Account Activity API CRC validation + event processing
// GET: CRC challenge response (crc_token query param) // GET: CRC challenge response (crc_token query param)
@@ -2129,3 +2138,272 @@ func requestHost(req *http.Request) string {
} }
return strings.Trim(host, "[]") return strings.Trim(host, "[]")
} }
func twilioVoiceCallTwiML(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCall(c, db)
if err != nil {
c.String(http.StatusNotFound, "Not found")
return
}
conferenceSID := strings.TrimSpace(call.ConferenceSID)
if conferenceSID == "" {
conferenceSID = fmt.Sprintf("conf_account_%d_call_%d", call.AccountID, call.ID)
_ = db.WithContext(c.Request.Context()).Model(call).Update("conference_sid", conferenceSID).Error
}
participantLabel := twilioParticipantLabel(c.PostForm("From"))
phoneDigits := twilioPhoneDigits(c.Param("phone"))
xml := fmt.Sprintf(
`<?xml version="1.0" encoding="UTF-8"?><Response><Dial><Conference startConferenceOnEnter="%t" endConferenceOnExit="false" record="record-from-start" recordingStatusCallback="/twilio/voice/recording_status/%s" recordingStatusCallbackEvent="completed" recordingStatusCallbackMethod="POST" statusCallback="/twilio/voice/conference_status/%s" statusCallbackEvent="start end join leave" statusCallbackMethod="POST" participantLabel="%s">%s</Conference></Dial></Response>`,
twilioAgentLeg(c.PostForm("From")),
html.EscapeString(phoneDigits),
html.EscapeString(phoneDigits),
html.EscapeString(participantLabel),
html.EscapeString(conferenceSID),
)
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(xml))
}
}
func twilioVoiceStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"status": twilioCallStatus(c.PostForm("CallStatus"))}
if duration, parseErr := strconv.Atoi(c.PostForm("CallDuration")); parseErr == nil {
updates["duration"] = duration
}
mergeCallAttributes(call, formPayload(c), "twilio_status_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func twilioVoiceConferenceStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
event := twilioConferenceEvent(c.PostForm("StatusCallbackEvent"))
if event == "" {
c.Status(http.StatusNoContent)
return
}
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"status": twilioConferenceCallStatus(event)}
attrs := formPayload(c)
if sid := strings.TrimSpace(c.PostForm("ConferenceSid")); sid != "" {
attrs["twilio_conference_sid"] = sid
}
attrs["event"] = event
mergeCallAttributes(call, attrs, "twilio_conference_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func twilioVoiceRecordingStatus(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
call, err := resolveTwilioVoiceCallbackCall(c, db)
if err == nil {
updates := map[string]any{"recording_url": strings.TrimSpace(c.PostForm("RecordingUrl"))}
if duration, parseErr := strconv.Atoi(c.PostForm("RecordingDuration")); parseErr == nil {
updates["duration"] = duration
}
mergeCallAttributes(call, formPayload(c), "twilio_recording_payload")
updates["additional_attributes"] = call.AdditionalAttributes
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
}
c.Status(http.StatusNoContent)
}
}
func resolveTwilioVoiceCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
if db == nil {
return nil, gorm.ErrRecordNotFound
}
inbox, err := findTwilioVoiceInbox(c, db)
if err != nil {
return nil, err
}
from := strings.TrimSpace(c.PostForm("From"))
if twilioAgentLeg(from) {
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("call_sid"))
}
direction := strings.TrimSpace(firstNonEmpty(c.PostForm("Direction"), c.PostForm("CallDirection")))
callSID := strings.TrimSpace(c.PostForm("CallSid"))
if direction == "outbound-dial" {
if parent := strings.TrimSpace(c.PostForm("ParentCallSid")); parent != "" {
callSID = parent
}
}
return findTwilioVoiceCall(c, db, inbox.ID, callSID)
}
func resolveTwilioVoiceCallbackCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
if db == nil {
return nil, gorm.ErrRecordNotFound
}
inbox, err := findTwilioVoiceInbox(c, db)
if err != nil {
return nil, err
}
if friendlyName := strings.TrimSpace(c.PostForm("FriendlyName")); friendlyName != "" {
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, friendlyName); err == nil {
return call, nil
}
}
if conferenceSID := strings.TrimSpace(c.PostForm("ConferenceSid")); conferenceSID != "" {
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, conferenceSID); err == nil {
return call, nil
}
}
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("CallSid"))
}
func findTwilioVoiceInbox(c *gin.Context, db *gorm.DB) (*model.Inbox, error) {
phone := "+" + twilioPhoneDigits(c.Param("phone"))
var channel channelmodel.ChannelTwilioSMS
if err := db.WithContext(c.Request.Context()).Where("phone_number = ?", phone).First(&channel).Error; err != nil {
return nil, err
}
var inbox model.Inbox
if err := db.WithContext(c.Request.Context()).Where("id = ? AND channel_type IN ?", channel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil {
return nil, err
}
if !twilioVoiceEnabled(inbox.ChannelConfig) {
return nil, gorm.ErrRecordNotFound
}
return &inbox, nil
}
func findTwilioVoiceCall(c *gin.Context, db *gorm.DB, inboxID uint, callSID string) (*model.Call, error) {
var call model.Call
err := db.WithContext(c.Request.Context()).
Where("inbox_id = ? AND provider = ? AND provider_call_id = ?", inboxID, "twilio", strings.TrimSpace(callSID)).
First(&call).Error
return &call, err
}
func findTwilioVoiceCallByConference(c *gin.Context, db *gorm.DB, inboxID uint, conferenceSID string) (*model.Call, error) {
var call model.Call
err := db.WithContext(c.Request.Context()).
Where("inbox_id = ? AND provider = ? AND conference_sid = ?", inboxID, "twilio", strings.TrimSpace(conferenceSID)).
First(&call).Error
return &call, err
}
func twilioVoiceEnabled(rawConfig string) bool {
config := map[string]any{}
if rawConfig != "" {
_ = json.Unmarshal([]byte(rawConfig), &config)
}
value, ok := config["voice_enabled"]
if !ok {
return false
}
switch v := value.(type) {
case bool:
return v
case string:
return strings.EqualFold(v, "true")
default:
return false
}
}
func twilioPhoneDigits(phone string) string {
var b strings.Builder
for _, r := range phone {
if r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
func twilioAgentLeg(from string) bool {
return strings.HasPrefix(strings.TrimSpace(from), "client:")
}
func twilioParticipantLabel(from string) string {
from = strings.TrimSpace(from)
if twilioAgentLeg(from) {
return strings.TrimPrefix(from, "client:")
}
return "contact"
}
func twilioCallStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case "in-progress", "answered":
return string(model.CallStatusOngoing)
case "completed":
return string(model.CallStatusCompleted)
case "failed", "busy", "no-answer", "canceled":
return string(model.CallStatusFailed)
default:
return string(model.CallStatusRinging)
}
}
func twilioConferenceEvent(event string) string {
event = strings.ToLower(strings.TrimSpace(event))
switch {
case strings.Contains(event, "conference-start"):
return "start"
case strings.Contains(event, "participant-join"):
return "join"
case strings.Contains(event, "participant-leave"):
return "leave"
case strings.Contains(event, "conference-end"):
return "end"
default:
return ""
}
}
func twilioConferenceCallStatus(event string) string {
if event == "end" {
return string(model.CallStatusCompleted)
}
return string(model.CallStatusOngoing)
}
func formPayload(c *gin.Context) map[string]any {
_ = c.Request.ParseForm()
payload := map[string]any{}
for key, values := range c.Request.PostForm {
if len(values) > 0 {
payload[key] = values[0]
}
}
return payload
}
func mergeCallAttributes(call *model.Call, value map[string]any, key string) {
attrs := map[string]any{}
if len(call.AdditionalAttributes) > 0 {
_ = json.Unmarshal(call.AdditionalAttributes, &attrs)
}
attrs[key] = value
data, err := json.Marshal(attrs)
if err != nil {
return
}
call.AdditionalAttributes = data
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
+149
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"strings" "strings"
"testing" "testing"
@@ -11,6 +12,7 @@ import (
"github.com/gochat/gochat/internal/config" "github.com/gochat/gochat/internal/config"
"github.com/gochat/gochat/internal/middleware" "github.com/gochat/gochat/internal/middleware"
"github.com/gochat/gochat/internal/model" "github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -69,6 +71,10 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
"POST /webhooks/whatsapp/:phone_number", "POST /webhooks/whatsapp/:phone_number",
"POST /webhooks/tiktok", "POST /webhooks/tiktok",
"POST /webhooks/shopify", "POST /webhooks/shopify",
"POST /twilio/voice/call/:phone",
"POST /twilio/voice/status/:phone",
"POST /twilio/voice/conference_status/:phone",
"POST /twilio/voice/recording_status/:phone",
} }
for _, key := range expected { for _, key := range expected {
@@ -78,6 +84,93 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
} }
} }
func TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks(t *testing.T) {
gin.SetMode(gin.TestMode)
db, call := setupRouterTwilioVoiceDB(t)
engine := gin.New()
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
twiml := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{
"CallSid": {call.ProviderCallID},
"Direction": {"outbound-api"},
"From": {"+15550990000"},
"ParentCallSid": {""},
})
if twiml.Code != http.StatusOK || !strings.Contains(twiml.Body.String(), "<Conference") || !strings.Contains(twiml.Body.String(), call.ConferenceSID) {
t.Fatalf("expected conference TwiML, got %d %q", twiml.Code, twiml.Body.String())
}
if !strings.Contains(twiml.Body.String(), `participantLabel="contact"`) {
t.Fatalf("expected contact participant label, got %s", twiml.Body.String())
}
status := performFormPost(engine, "/twilio/voice/status/15551234567", url.Values{
"CallSid": {call.ProviderCallID},
"CallStatus": {"completed"},
"CallDuration": {"42"},
})
if status.Code != http.StatusNoContent {
t.Fatalf("expected status callback 204, got %d", status.Code)
}
conference := performFormPost(engine, "/twilio/voice/conference_status/15551234567", url.Values{
"CallSid": {call.ProviderCallID},
"FriendlyName": {call.ConferenceSID},
"ConferenceSid": {"CF123"},
"StatusCallbackEvent": {"participant-join"},
})
if conference.Code != http.StatusNoContent {
t.Fatalf("expected conference callback 204, got %d", conference.Code)
}
recording := performFormPost(engine, "/twilio/voice/recording_status/15551234567", url.Values{
"CallSid": {call.ProviderCallID},
"RecordingUrl": {"https://api.twilio.com/recording.mp3"},
"RecordingDuration": {"43"},
"RecordingStatus": {"completed"},
"RecordingSource": {"Conference"},
"RecordingChannels": {"1"},
"RecordingStartTime": {"Sat, 06 Jun 2026 09:00:00 +0000"},
})
if recording.Code != http.StatusNoContent {
t.Fatalf("expected recording callback 204, got %d", recording.Code)
}
var updated model.Call
if err := db.First(&updated, call.ID).Error; err != nil {
t.Fatalf("failed to reload call: %v", err)
}
if updated.Status != string(model.CallStatusOngoing) || updated.Duration != 43 || updated.RecordingURL != "https://api.twilio.com/recording.mp3" {
t.Fatalf("expected persisted Twilio callback state, got status=%s duration=%d recording=%s", updated.Status, updated.Duration, updated.RecordingURL)
}
if !strings.Contains(string(updated.AdditionalAttributes), "twilio_conference_sid") || !strings.Contains(string(updated.AdditionalAttributes), "twilio_recording_payload") {
t.Fatalf("expected callback payload attrs, got %s", string(updated.AdditionalAttributes))
}
}
func TestTwilioVoiceCallRejectsUnknownOrDisabledInbox(t *testing.T) {
gin.SetMode(gin.TestMode)
db, call := setupRouterTwilioVoiceDB(t)
if err := db.Model(&model.Inbox{}).Where("id = ?", call.InboxID).Update("channel_config", `{"voice_enabled":false}`).Error; err != nil {
t.Fatalf("failed to disable voice inbox: %v", err)
}
engine := gin.New()
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
disabled := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{"CallSid": {call.ProviderCallID}})
if disabled.Code != http.StatusNotFound {
t.Fatalf("expected disabled voice inbox 404, got %d", disabled.Code)
}
missing := performFormPost(engine, "/twilio/voice/call/15550000000", url.Values{"CallSid": {call.ProviderCallID}})
if missing.Code != http.StatusNotFound {
t.Fatalf("expected missing voice inbox 404, got %d", missing.Code)
}
}
func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) { func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
db := setupRouterPortalDB(t) db := setupRouterPortalDB(t)
@@ -214,6 +307,14 @@ func performHostGet(engine *gin.Engine, path string, host string) *httptest.Resp
return recorder return recorder
} }
func performFormPost(engine *gin.Engine, path string, form url.Values) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
engine.ServeHTTP(recorder, req)
return recorder
}
func setupRouterPortalDB(t *testing.T) *gorm.DB { func setupRouterPortalDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
@@ -227,3 +328,51 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB {
} }
return db return db
} }
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{
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.ChannelTwilioSMS{}, &model.Call{}); err != nil {
t.Fatalf("failed to migrate twilio voice models: %v", err)
}
account := &model.Account{Name: "Voice Account", Locale: "en"}
if err := db.Create(account).Error; err != nil {
t.Fatalf("failed to create account: %v", err)
}
inbox := &model.Inbox{AccountID: account.ID, Name: "Voice", ChannelType: "twilio_sms", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
if err := db.Create(inbox).Error; err != nil {
t.Fatalf("failed to create inbox: %v", err)
}
channel := &channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
if err := db.Create(channel).Error; err != nil {
t.Fatalf("failed to create twilio channel: %v", err)
}
call := &model.Call{
AccountID: account.ID,
InboxID: inbox.ID,
ConversationID: 1,
Provider: "twilio",
ProviderCallID: "CA123",
ConferenceSID: "conf_account_1_call_1",
CallerType: "User",
CallerID: 1,
Status: string(model.CallStatusRinging),
CallDirection: "outbound",
Direction: "outgoing",
AdditionalAttributes: json.RawMessage(`{}`),
AcceptedByAgentID: nil,
ContactID: 1,
MessageID: nil,
RecordingURL: "",
Duration: 0,
}
if err := db.Create(call).Error; err != nil {
t.Fatalf("failed to create call: %v", err)
}
return db, call
}