From 6b9049d2848c028c8d0c34847c0223ebc11281f8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 6 Jun 2026 09:34:06 +0800 Subject: [PATCH] feat(twilio): expose root callback --- cmd/route_parity/main.go | 2 + docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 14 ++-- docs/parity/gochat_routes.txt | 3 +- docs/parity/route_parity.md | 4 +- internal/handler/webhook/twilio_webhook.go | 76 +++++++++++++++++++ .../handler/webhook/webhook_lookup_test.go | 67 ++++++++++++++++ internal/router/router.go | 7 ++ internal/router/router_test.go | 2 + 8 files changed, 167 insertions(+), 8 deletions(-) diff --git a/cmd/route_parity/main.go b/cmd/route_parity/main.go index 04d0ea3f..7a6cca74 100644 --- a/cmd/route_parity/main.go +++ b/cmd/route_parity/main.go @@ -29,6 +29,8 @@ 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/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: "POST", Path: "/twilio/callback", Controller: "twilio/callback#create", Source: "routes.rb:639"}, + {Method: "POST", Path: "/twilio/delivery_status", Controller: "twilio/delivery_status#create", Source: "routes.rb:640"}, {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"}, diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 42f134db..ba994958 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,14 +49,14 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(twilio): expose voice callbacks`. -- Latest implementation checkpoint: this checkpoint, prepared as `feat(twilio): expose voice callbacks`. +- Current tracking checkpoint: 2026-06-06 after this implementation checkpoint, prepared as `feat(twilio): expose root callback`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(twilio): expose root callback`. - 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`; 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. +- 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`. 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`, plus `/twilio/voice/call/:phone`, `/twilio/voice/status/:phone`, `/twilio/voice/conference_status/:phone`, and `/twilio/voice/recording_status/:phone`. Twilio root callbacks acknowledge with `204 No Content`, resolve channels by `To` or `MessagingServiceSid`, and persist inbound messages through the existing provider incoming boundary; 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 2/3 callback drift slice, Phase 6 placeholder burn-down item, or B12 live smoke finding. - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 891` after adding the enterprise Twilio voice callback routes. +- Route dump succeeds with `TOTAL: 892` after adding the Chatwoot root Twilio callback route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. -- 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. +- Tracked frontend-critical route audit covers 354 Chatwoot routes: 345 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 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. | +| 1 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 354-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`, 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, 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. | | 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(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. | | `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. | @@ -2353,3 +2354,4 @@ Verification milestone gates: - 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: 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. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index a886f7f1..c76622a3 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -787,6 +787,7 @@ 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_status 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 /twilio/callback POST /twilio/delivery_status POST /twilio/voice/call/:phone POST /twilio/voice/conference_status/:phone @@ -889,4 +890,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: 891 +TOTAL: 892 diff --git a/docs/parity/route_parity.md b/docs/parity/route_parity.md index 5154831c..5a40b324 100644 --- a/docs/parity/route_parity.md +++ b/docs/parity/route_parity.md @@ -7,7 +7,7 @@ Generated from: This report covers tracked frontend-critical Chatwoot routes from `reference/chatwoot/config/routes.rb`, including API v1 account routes, Captain/Copilot, assignment policies, widget/public APIs, and API v2 reports. Ruby is not installed in the workspace, so Chatwoot routes are sourced from static route declarations instead of `bin/rails routes`. -Summary: 343 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 352 tracked critical routes. +Summary: 345 exact, 0 method-compatible, 9 parameter-compatible, 0 missing out of 354 tracked critical routes. ## Missing Critical Routes @@ -347,6 +347,8 @@ 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_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 | `/twilio/callback` | `/twilio/callback` | `twilio/callback#create` | `routes.rb:639` | exact | +| POST | `/twilio/delivery_status` | `/twilio/delivery_status` | `twilio/delivery_status#create` | `routes.rb:640` | 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 | diff --git a/internal/handler/webhook/twilio_webhook.go b/internal/handler/webhook/twilio_webhook.go index c38c07d3..1d5bf1ce 100644 --- a/internal/handler/webhook/twilio_webhook.go +++ b/internal/handler/webhook/twilio_webhook.go @@ -14,6 +14,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "github.com/gochat/gochat/internal/channel" twiliochannel "github.com/gochat/gochat/internal/channel/twilio" @@ -80,6 +81,43 @@ func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) { c.Data(http.StatusOK, "application/xml", []byte("")) } +// HandleTwilioCallback processes Chatwoot's root Twilio callback route. +// Reference: Chatwoot Twilio::CallbackController#create enqueues the event and +// always acknowledges with 204 No Content. +func (h *TwilioWebhookHandler) HandleTwilioCallback(c *gin.Context) { + if err := c.Request.ParseForm(); err != nil { + applogger.L().Errorf("Twilio callback: parse form failed: %v", err) + c.Status(http.StatusNoContent) + return + } + + if h.twilioWebhook == nil { + applogger.L().Warn("Twilio callback: Twilio webhook pipeline is not configured") + c.Status(http.StatusNoContent) + return + } + + inbox, err := h.lookupCallbackInbox(c.Request.Form) + if err != nil { + applogger.L().Warnf("Twilio callback: inbox lookup failed: %v", err) + c.Status(http.StatusNoContent) + return + } + + incomingMsg, err := h.twilioWebhook.ProcessInboundSMS(c.Request, inbox) + if err != nil { + applogger.L().Errorf("Twilio callback: process inbound SMS failed for inbox %d: %v", inbox.ID, err) + c.Status(http.StatusNoContent) + return + } + if incomingMsg != nil { + if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil { + applogger.L().Errorf("Twilio callback: persist inbound SMS failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr) + } + } + c.Status(http.StatusNoContent) +} + // HandleTwilioDeliveryStatus processes a Twilio delivery status callback. func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) { phoneNumber := c.Param("phone_number") @@ -144,6 +182,44 @@ func (h *TwilioWebhookHandler) lookupDeliveryStatusInbox(params url.Values) (*mo return &inbox, nil } +func (h *TwilioWebhookHandler) lookupCallbackInbox(params url.Values) (*model.Inbox, error) { + if h.db == nil { + return nil, fmt.Errorf("twilio webhook database is not configured") + } + var twilioChannel channelmodel.ChannelTwilioSMS + query := h.db + if phone := normalizeTwilioPhone(params.Get("To")); phone != "" { + if accountSID := params.Get("AccountSid"); accountSID != "" { + query = query.Where(&channelmodel.ChannelTwilioSMS{AccountSID: accountSID, PhoneNumber: phone}) + } else { + query = query.Where(&channelmodel.ChannelTwilioSMS{PhoneNumber: phone}) + } + } else if sid := params.Get("MessagingServiceSid"); sid != "" { + query = query.Where(&channelmodel.ChannelTwilioSMS{MessagingServiceSID: sid}) + } else { + return nil, fmt.Errorf("twilio callback missing To or MessagingServiceSid") + } + if err := query.First(&twilioChannel).Error; err != nil { + return nil, err + } + var inbox model.Inbox + if err := h.db.Where("id = ? AND channel_type IN ?", twilioChannel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil { + return nil, err + } + return &inbox, nil +} + +func normalizeTwilioPhone(phone string) string { + phone = strings.TrimSpace(phone) + if phone == "" { + return "" + } + if strings.HasPrefix(phone, "+") { + return phone + } + return "+" + phone +} + func mapTwilioMessageStatus(status string) (model.MessageStatus, bool) { switch status { case "sent", "queued", "accepted", "sending": diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index b2696175..499e5946 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -805,6 +805,73 @@ func TestTwilioWebhookPersistsIncomingMessage(t *testing.T) { assertPersistedMessage(t, db, inbox.ID, "SMIN1", "hello sms") } +func TestTwilioCallbackExactRoutePersistsIncomingMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "twilio_sms") + channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567", MessagingServiceSID: "MG123"} + if err := db.Create(&channelRecord).Error; err != nil { + t.Fatalf("create twilio channel: %v", err) + } + + twilioRepo := twiliochannel.NewRepository(db) + twilioService := twiliochannel.NewTwilioService(twilioRepo) + twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService) + twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService) + h := NewTwilioWebhookHandler(twilioWebhook, db) + r := gin.New() + r.POST("/twilio/callback", h.HandleTwilioCallback) + form := url.Values{} + form.Set("MessageSid", "SMROOT1") + form.Set("AccountSid", "AC123") + form.Set("From", "+15550002222") + form.Set("To", "15551234567") + form.Set("Body", "hello root callback") + req := httptest.NewRequest(http.MethodPost, "/twilio/callback", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d body=%s", w.Code, w.Body.String()) + } + assertPersistedMessage(t, db, inbox.ID, "SMROOT1", "hello root callback") +} + +func TestTwilioCallbackExactRouteFallsBackToMessagingServiceSid(t *testing.T) { + gin.SetMode(gin.TestMode) + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "twilio_sms") + channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567", MessagingServiceSID: "MG123"} + if err := db.Create(&channelRecord).Error; err != nil { + t.Fatalf("create twilio channel: %v", err) + } + + twilioRepo := twiliochannel.NewRepository(db) + twilioService := twiliochannel.NewTwilioService(twilioRepo) + twilioPipeline := twiliochannel.NewIncomingProcessor(twilioService) + twilioWebhook := twiliochannel.NewWebhookHandler(twilioPipeline, twilioService) + h := NewTwilioWebhookHandler(twilioWebhook, db) + r := gin.New() + r.POST("/twilio/callback", h.HandleTwilioCallback) + form := url.Values{} + form.Set("MessageSid", "SMROOTMG1") + form.Set("MessagingServiceSid", "MG123") + form.Set("From", "+15550002222") + form.Set("Body", "hello service callback") + req := httptest.NewRequest(http.MethodPost, "/twilio/callback", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d body=%s", w.Code, w.Body.String()) + } + assertPersistedMessage(t, db, inbox.ID, "SMROOTMG1", "hello service callback") +} + func TestTwilioInboundSMSQueuesIncomingMessageWithWorker(t *testing.T) { gin.SetMode(gin.TestMode) db := newWebhookLookupTestDB(t) diff --git a/internal/router/router.go b/internal/router/router.go index e02a379c..e4e1c4f7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -451,6 +451,13 @@ func RegisterRoutes( } handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c) }) + engine.POST("/twilio/callback", func(c *gin.Context) { + if handlers == nil || handlers.TwilioWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TwilioWebhook.HandleTwilioCallback(c) + }) // Enterprise Twilio voice callback routes. // Reference: Chatwoot enterprise Twilio::VoiceController routes.rb:643-646. engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db)) diff --git a/internal/router/router_test.go b/internal/router/router_test.go index dd11120d..48fb1216 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -71,6 +71,8 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "POST /webhooks/whatsapp/:phone_number", "POST /webhooks/tiktok", "POST /webhooks/shopify", + "POST /twilio/callback", + "POST /twilio/delivery_status", "POST /twilio/voice/call/:phone", "POST /twilio/voice/status/:phone", "POST /twilio/voice/conference_status/:phone",