From 9e3f561bed823e6854381bf72310ebcb2cb9495b Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 4 Jun 2026 23:20:58 +0800 Subject: [PATCH] feat(webhook): align chatwoot ingress routes --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 23 +-- docs/parity/gochat_routes.txt | 28 ++-- internal/app/bootstrap.go | 2 +- internal/handler/webhook/line_webhook.go | 48 ++++-- internal/handler/webhook/telegram_webhook.go | 33 ++-- internal/handler/webhook/tiktok_webhook.go | 87 +++++++++-- internal/handler/webhook/twilio_webhook.go | 51 ++++-- .../handler/webhook/webhook_lookup_test.go | 142 +++++++++++++++++ internal/middleware/webhook_auth.go | 7 +- internal/model/channel/tiktok.go | 10 +- internal/router/router.go | 147 ++++++++++++++---- internal/router/router_test.go | 9 ++ 12 files changed, 467 insertions(+), 120 deletions(-) create mode 100644 internal/handler/webhook/webhook_lookup_test.go diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index d9904fab..a0274aa8 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -17,7 +17,7 @@ Build GoChat as a Go backend that can directly reuse the frontend from `referenc ## Current Baseline - `go test ./...` passes. -- Route dump succeeds with `TOTAL: 793` after widget direct-upload routes were added. +- Route dump succeeds with `TOTAL: 801` after Chatwoot webhook ingress routes were added. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. - Tracked frontend-critical route audit covers 251 Chatwoot routes: 251 exact, 0 method-compatible, 0 parameter-compatible, 0 missing. - `/api/v1/widget` stubs are burned down and public inbox/contact/conversation/message core flows are backed by real handlers. @@ -60,7 +60,7 @@ This is the ordered queue for the next implementation slices. Do not skip the ro | Q3 | Add route boot regression coverage for wildcard conflict groups before expanding more Rails-style resources. | Phase 2 | Router tests cover nested dynamic resources that previously risked Gin conflicts. | Done | | Q4 | Start serializer parity fixtures for auth/session, conversations/messages, contacts/companies, inboxes, notifications, and search. | Phase 3 | Each area has at least one reference fixture and Go response test. | Doing | | Q5 | Review Meilisearch document shape and endpoint payloads against Chatwoot frontend consumers. | Phase 1 and Phase 3 | Search remains Meilisearch-first and payload mismatches are fixed or tracked. | Todo | -| Q6 | Implement provider-specific webhook ingress for Chatwoot public webhook paths. | Phase 6 | Generic webhook placeholder no longer masks provider gaps; Telegram, LINE, SMS/Twilio, WhatsApp, Instagram/Twitter/TikTok routes resolve and verify like Chatwoot where supported. | Todo | +| Q6 | Implement provider-specific webhook ingress for Chatwoot public webhook paths. | Phase 6 | Generic webhook placeholder no longer masks provider gaps; Telegram, LINE, SMS/Twilio, WhatsApp, Instagram/Twitter/TikTok routes resolve and verify like Chatwoot where supported. | Doing | | Q7 | Burn down enterprise gaps in this order: SLA, assignment policy and capacity, CSAT, automation/macros, Audit, CustomRole, InboxLimit, Captain/Copilot. | Phase 4 and Phase 5 | Each feature passes route, persistence, auth, side-effect, response, and test checks. | Todo | | Q8 | Add frontend smoke harness using the reused Chatwoot frontend once core API flows boot end-to-end. | Phase 7 | Login, inbox list, conversation list/detail, message send, contact view, and widget init run without frontend adapters. | Todo | @@ -97,7 +97,7 @@ Work proceeds top-down unless a failing test or frontend blocker forces a narrow | Slice | Work | Reference source | Verification | Status | | --- | --- | --- | --- | --- | -| B1 | Webhook ingress route and handler parity. | `reference/chatwoot/config/routes.rb:614-624`, `reference/chatwoot/app/controllers/webhooks/*`, `reference/chatwoot/app/controllers/api/v1/webhooks_controller.rb` | Provider lookup tests, router route dump, `go test ./...`. | Next | +| B1 | Webhook ingress route and handler parity. | `reference/chatwoot/config/routes.rb:614-624`, `reference/chatwoot/app/controllers/webhooks/*`, `reference/chatwoot/app/controllers/api/v1/webhooks_controller.rb` | Provider lookup tests, router route dump, `go test ./...`. | Doing | | B2 | Auth/profile serializer fixtures. | `reference/chatwoot/app/controllers/api/v1/profile*`, frontend auth client. | Fixture tests for login/current user/profile/availability/settings. | Todo | | B3 | Conversation/message serializer and behavior fixtures. | Chatwoot conversation/message controllers, entities, jobs. | Fixture tests for list/show/create/update/private notes/attachments/status/assignment. | Todo | | B4 | Contact/company behavior fixtures. | Chatwoot contact/company controllers, merge/import/export/notes/labels. | Fixture tests for CRUD/search/merge/relation/import-export shells. | Todo | @@ -464,21 +464,21 @@ Tracking table: | P6.4 | Message APIs | `docs/ROUTE_GAP_ANALYSIS.md`, message handlers/services | Implement create/list/delete, private notes, attachments, source attribution, events. | Todo | | P6.5 | Inbox APIs | `docs/ROUTE_GAP_ANALYSIS.md`, inbox handlers/services | Implement CRUD, assignable agents, avatar, campaigns, channel settings, reset secret. | Todo | | P6.6 | Widget/public APIs | `docs/ROUTE_GAP_ANALYSIS.md`, widget/channel provider code, `chatwootParityStub` routes | Widget/public frontend-critical route behavior is handler-backed, including public inbox flow, direct uploads/attachments, and public CSAT survey submission. | Done | -| P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Todo | +| P6.7 | Webhook ingress | `internal/router/router.go`, `internal/handler/webhook/*`, channel providers | Replace generic placeholder with provider-specific verified ingestion and dispatch. | Doing | Webhook ingress subtracking: | ID | Provider/path | Chatwoot reference | Current Go gap | Done when | Status | | --- | --- | --- | --- | --- | --- | -| P6.7a | Twitter `GET/POST /webhooks/twitter` | `api/v1/webhooks#twitter_crc`, `#twitter_events` | Go route currently exposes `/webhooks/twitter/webhook`; Chatwoot alias is missing from the public route surface. | CRC and event routes exist at Chatwoot paths and use the existing Twitter handlers/tests. | Todo | -| P6.7b | LINE `POST /webhooks/line/:line_channel_id` | `webhooks/line#process_payload` | Router param and handler lookup are mismatched; handler reads an inbox-style param instead of line channel ID. | Handler resolves `ChannelLINE` by `channel_id`, verifies `X-Line-Signature`, and dispatches/acks like Chatwoot. | Todo | -| P6.7c | Telegram `POST /webhooks/telegram/:bot_token` | `webhooks/telegram#process_payload` | Handler lookup is a placeholder and does not resolve the real inbox by bot token. | Handler resolves `ChannelTelegram` by `bot_token`, loads inbox, processes update, and returns provider-safe `200 OK`. | Todo | -| P6.7d | SMS/Twilio `POST /webhooks/sms/:phone_number` | `webhooks/sms#process_payload` | Go path is `/webhooks/twilio/sms/:phone_number`; handler reads an inbox-style param. | Chatwoot path is registered, phone number resolves `ChannelTwilioSMS`, signature verification is applied where configured, and message/status events dispatch. | Todo | -| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Param naming and verification/secret behavior need Chatwoot comparison; existing handler mostly delegates to channel package. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Todo | +| P6.7a | Twitter `GET/POST /webhooks/twitter` | `api/v1/webhooks#twitter_crc`, `#twitter_events` | Go route exposed only `/webhooks/twitter/webhook`. | CRC and event routes exist at Chatwoot paths and use the existing Twitter handlers/tests. | Done | +| P6.7b | LINE `POST /webhooks/line/:line_channel_id` | `webhooks/line#process_payload` | Router param and handler lookup were mismatched; handler read an inbox-style param instead of line channel ID. | Handler resolves `ChannelLINE` by `channel_id`, verifies `X-Line-Signature`, and dispatches/acks like Chatwoot. | Review | +| P6.7c | Telegram `POST /webhooks/telegram/:bot_token` | `webhooks/telegram#process_payload` | Handler lookup was a placeholder and did not resolve the real inbox by bot token. | Handler resolves `ChannelTelegram` by `bot_token`, loads inbox, processes update, and returns provider-safe `200 OK`. | Review | +| P6.7d | SMS/Twilio `POST /webhooks/sms/:phone_number` | `webhooks/sms#process_payload` | Go path was `/webhooks/twilio/sms/:phone_number`; handler read an inbox-style param. | Chatwoot path is registered, phone number resolves `ChannelTwilioSMS`, signature verification is applied where configured, and message/status events dispatch. | Review | +| P6.7e | WhatsApp `GET/POST /webhooks/whatsapp/:phone_number` | `webhooks/whatsapp#verify`, `#process_payload` | Param naming and verification/secret behavior need Chatwoot comparison; existing handler mostly delegates to channel package. | Verify challenge and POST event ingestion match Chatwoot path, token, response, and inbox resolution behavior. | Review | | P6.7f | Instagram `GET/POST /webhooks/instagram` | `webhooks/instagram#verify`, `#events` | Chatwoot no-param route is absent; Meta verification/signature behavior is not exposed separately from Facebook routes. | Verify/event routes exist at Chatwoot paths and resolve account/inbox from payload/subscription data. | Todo | -| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expects `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, resolves business/inbox from payload, and acks/dispatches provider events. | Todo | +| P6.7g | TikTok `POST /webhooks/tiktok` | `webhooks/tiktok#events` | Go route expected `:business_id`; Chatwoot route has no path param and should derive identity from payload. | Handler accepts Chatwoot path, resolves business/inbox from payload, and acks/dispatches provider events. | Review | | P6.7h | Shopify `POST /webhooks/shopify` | `webhooks/shopify#events` | Chatwoot route exists; Go provider surface needs inventory before implementation. | Route either has a real verified handler or is explicitly tracked as unsupported without placeholder success. | Todo | -| P6.7i | Generic fallback and auth middleware | Go `WebhookAuth`, `webhookStub` | Generic middleware reads `:channel_type/:identifier`, which breaks provider-specific routes; fallback currently returns placeholder success. | Provider routes perform provider-specific verification; fallback no longer masks missing providers with success JSON. | Todo | +| P6.7i | Generic fallback and auth middleware | Go `WebhookAuth`, `webhookStub` | Generic middleware reads `:channel_type/:identifier`, which breaks provider-specific routes; fallback returned placeholder success. | Provider routes perform provider-specific verification; fallback no longer masks missing providers with success JSON. | Done | P6.7 implementation notes: @@ -563,3 +563,4 @@ Verification milestone gates: - 2026-06-04: Completed P6.6e widget direct upload/attachment parity for the reused Chatwoot widget frontend. `/api/v1/widget/direct_uploads` now accepts ActiveStorage metadata with `website_token` + `X-Auth-Token`, returns the raw `signed_id/direct_upload` blob shape expected by `DirectUpload`, supports the follow-up PUT body upload, and attaches `message[attachments][]` signed IDs to incoming widget messages. Message create/list payloads now include Chatwoot-style attachment fields (`data_url`, `thumb_url`, `file_type`, extension, size). Added focused handler coverage for ActiveStorage create/PUT and multipart attachment-only message send/list. Verified focused package tests and regenerated route artifacts; route dump now reports `TOTAL: 793`, while tracked parity remains `251 exact, 0 missing`. Remaining P6.6 work is deeper public CSAT behavior. - 2026-06-04: Completed P6.6f public CSAT deep behavior. `/public/api/v1/csat_survey/:id` now resolves the conversation UUID to the `input_csat` message and returns the Chatwoot public survey payload (`csat_survey_response`, display type, inbox avatar/name, locale, conversation/message IDs). Public CSAT submit now accepts nested `message.submitted_values`, updates the survey message content attributes, upserts a message-linked CSAT response, and enforces Chatwoot's 14-day lock with `422`. Public inbox message update now applies the same lock/response-builder path for `input_csat` messages. Added handler coverage for public CSAT show/update/lock and public inbox CSAT message update/lock. Focused package tests passed. - 2026-06-04: Consolidated the Hermes-era planning into this master tracker. Added the locked decision ledger, end-to-end milestone map, ordered slice backlog, enterprise work package breakdown, and detailed P6.7 provider webhook ingress checklist. Current next implementation slice is B1/P6.7 webhook ingress. +- 2026-06-04: Started P6.7 webhook ingress parity. Added Chatwoot public webhook paths for Twitter, Telegram, LINE, SMS/Twilio, WhatsApp, Instagram, TikTok, and Shopify; removed the generic success fallback so unsupported providers no longer return placeholder success. Telegram, LINE, Twilio SMS, and TikTok handlers now resolve inboxes through provider channel records instead of inbox-id placeholders; TikTok model column naming now matches existing repository queries. Added provider lookup tests and router boot coverage. Regenerated route dump: `TOTAL: 801`; tracked route parity remains `251 exact, 0 missing`. diff --git a/docs/parity/gochat_routes.txt b/docs/parity/gochat_routes.txt index 8840ca96..28e88a66 100644 --- a/docs/parity/gochat_routes.txt +++ b/docs/parity/gochat_routes.txt @@ -1,4 +1,3 @@ -CONNECT /webhooks/:channel_type/:identifier DELETE /api/v1/accounts/:account_id DELETE /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id DELETE /api/v1/accounts/:account_id/agent_bots/:agent_bot_id @@ -109,7 +108,6 @@ DELETE /platform/api/v1/apps/:id/permissibles/:permissible_id DELETE /platform/api/v1/banners/:id DELETE /platform/api/v1/installation_configs/:id DELETE /platform/api/v1/users/:id -DELETE /webhooks/:channel_type/:identifier GET /api/v1/accounts/ GET /api/v1/accounts/:account_id GET /api/v1/accounts/:account_id/agent_bot_inboxes/ @@ -420,7 +418,11 @@ GET /public/api/v1/inboxes/:inbox_id/contacts/:contact_id 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 /webhooks/:channel_type/:identifier +GET /webhooks/instagram +GET /webhooks/tiktok/:business_id +GET /webhooks/twitter +GET /webhooks/twitter/webhook +GET /webhooks/whatsapp/:phone_number GET /widget/cable_token GET /widget/conversations GET /widget/conversations/:id/messages @@ -428,8 +430,6 @@ GET /widget/widget/:website_token/pre_chat_form GET /widget/widget/:website_token/theme_config GET /widget/widget/:website_token/uploads/:upload_uuid GET /ws -HEAD /webhooks/:channel_type/:identifier -OPTIONS /webhooks/:channel_type/:identifier PATCH /api/v1/accounts/:account_id/agent_bot_inboxes/:agent_bot_inbox_id/status PATCH /api/v1/accounts/:account_id/channels/facebook_channel/:fb_id PATCH /api/v1/accounts/:account_id/contacts/:contact_id/notes/:note_id @@ -465,7 +465,6 @@ PATCH /platform/api/v1/users/:id PATCH /public/api/v1/csat_survey/:id PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id PATCH /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id -PATCH /webhooks/:channel_type/:identifier PATCH /widget/contact POST /api/v1/accounts/ POST /api/v1/accounts/:account_id/agent_bot_inboxes/ @@ -710,7 +709,18 @@ 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 /webhooks/:channel_type/:identifier +POST /webhooks/instagram +POST /webhooks/line/:line_channel_id +POST /webhooks/shopify +POST /webhooks/sms/:phone_number +POST /webhooks/telegram/:bot_token +POST /webhooks/tiktok +POST /webhooks/tiktok/:business_id +POST /webhooks/twilio/sms/:phone_number +POST /webhooks/twilio/status/:phone_number +POST /webhooks/twitter +POST /webhooks/twitter/webhook +POST /webhooks/whatsapp/:phone_number POST /widget/conversations/:id/toggle_typing POST /widget/direct_uploads POST /widget/init @@ -788,7 +798,5 @@ PUT /platform/api/v1/installation_configs/:id 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 /webhooks/:channel_type/:identifier PUT /widget/direct_uploads/:upload_uuid -TRACE /webhooks/:channel_type/:identifier -TOTAL: 793 +TOTAL: 801 diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 370e6702..8f7d7db9 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -396,7 +396,7 @@ func Bootstrap(env string) (*App, error) { // Create Telegram webhook handler (Gin HTTP handler for Telegram webhook endpoint) telWebhook := telegramchannel.NewWebhookHandler(tgProvider) - telegramWebhookHandler := webhook.NewTelegramWebhookHandler(tgProvider, telWebhook) + telegramWebhookHandler := webhook.NewTelegramWebhookHandler(tgProvider, telWebhook, db) // Step 8c: Wire WhatsApp channel provider (Cloud API / 360dialog) // Reference: Chatwoot registers WhatsApp channel providers in config/initializers/channels.rb diff --git a/internal/handler/webhook/line_webhook.go b/internal/handler/webhook/line_webhook.go index f0d34115..fa94cb04 100644 --- a/internal/handler/webhook/line_webhook.go +++ b/internal/handler/webhook/line_webhook.go @@ -3,17 +3,18 @@ package webhook // LineWebhookHandler processes incoming LINE webhook HTTP requests via Gin. // Reference: Facebook webhook adapter pattern (facebook_webhook.go) // -// URL pattern: /webhooks/line/:inbox_id +// URL pattern: /webhooks/line/:line_channel_id // Method: POST (LINE Messaging API sends events as JSON) import ( "encoding/json" + "fmt" "io" "net/http" - "strconv" linechannel "github.com/gochat/gochat/internal/channel/line" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gin-gonic/gin" @@ -40,18 +41,20 @@ func NewLineWebhookHandler(lineWebhook *linechannel.WebhookHandler, pipeline *li // HandleLineWebhook processes an incoming LINE webhook Gin request. func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { - inboxIDStr := c.Param("inbox_id") - inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) - if err != nil { - applogger.L().Warnf("LINE webhook: invalid inbox_id %s", inboxIDStr) + lineChannelID := c.Param("line_channel_id") + if lineChannelID == "" { + lineChannelID = c.Param("channel_id") + } + if lineChannelID == "" { + applogger.L().Warn("LINE webhook: missing line_channel_id in path") c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } // Lookup inbox from database - inbox, err := h.lookupInbox(uint(inboxID)) + inbox, err := h.lookupInboxByLineChannelID(lineChannelID) if err != nil { - applogger.L().Warnf("LINE webhook: inbox lookup failed for id %d: %v", inboxID, err) + applogger.L().Warnf("LINE webhook: inbox lookup failed for channel_id %s: %v", lineChannelID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } @@ -59,7 +62,7 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { // Read request body body, err := io.ReadAll(c.Request.Body) if err != nil { - applogger.L().Errorf("LINE webhook: failed to read body for inbox %d: %v", inboxID, err) + applogger.L().Errorf("LINE webhook: failed to read body for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } @@ -75,7 +78,7 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { if channelSecret != "" && signature != "" { if !h.service.VerifySignature(channelSecret, string(body), signature) { - applogger.L().Warnf("LINE webhook: invalid signature for inbox=%d", inboxID) + applogger.L().Warnf("LINE webhook: invalid signature for inbox=%d", inbox.ID) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) return } @@ -84,7 +87,7 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { // Parse webhook event var webhookEvent linechannel.WebhookEvent if err := json.Unmarshal(body, &webhookEvent); err != nil { - applogger.L().Errorf("LINE webhook: failed to parse JSON for inbox %d: %v", inboxID, err) + applogger.L().Errorf("LINE webhook: failed to parse JSON for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) return } @@ -93,7 +96,7 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) { for _, event := range webhookEvent.Events { incomingMsg, err := h.pipeline.ProcessEvent(c.Request.Context(), inbox, event) if err != nil { - applogger.L().Errorf("LINE webhook: process event failed for inbox %d: %v", inboxID, err) + applogger.L().Errorf("LINE webhook: process event failed for inbox %d: %v", inbox.ID, err) continue } if incomingMsg != nil { @@ -119,6 +122,25 @@ func (h *LineWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) { return &inbox, nil } +// lookupInboxByLineChannelID fetches an Inbox through the LINE channel record. +// Chatwoot exposes /webhooks/line/:line_channel_id and resolves the channel from that URL segment. +func (h *LineWebhookHandler) lookupInboxByLineChannelID(lineChannelID string) (*model.Inbox, error) { + if h.db == nil { + return nil, fmt.Errorf("line webhook database is not configured") + } + + var channel channelmodel.ChannelLINE + if err := h.db.Where("channel_id = ?", lineChannelID).First(&channel).Error; err != nil { + return nil, fmt.Errorf("line channel not found for channel_id=%s: %w", lineChannelID, err) + } + + var inbox model.Inbox + if err := h.db.Where("id = ? AND channel_type = ?", channel.InboxID, "line").First(&inbox).Error; err != nil { + return nil, fmt.Errorf("line inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) + } + return &inbox, nil +} + // parseChannelConfig parses the JSON-encoded ChannelConfig string into a map. func (h *LineWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} { if inbox.ChannelConfig == "" { @@ -130,4 +152,4 @@ func (h *LineWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]i return map[string]interface{}{} } return config -} \ No newline at end of file +} diff --git a/internal/handler/webhook/telegram_webhook.go b/internal/handler/webhook/telegram_webhook.go index a9b28126..423a8738 100644 --- a/internal/handler/webhook/telegram_webhook.go +++ b/internal/handler/webhook/telegram_webhook.go @@ -2,6 +2,7 @@ package webhook import ( "encoding/json" + "fmt" "io" "net/http" @@ -10,7 +11,9 @@ import ( channelprovider "github.com/gochat/gochat/internal/channel/provider" telegramchannel "github.com/gochat/gochat/internal/channel/telegram" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" + "gorm.io/gorm" ) // TelegramWebhookHandler processes incoming Telegram webhook HTTP requests via Gin. @@ -32,17 +35,19 @@ import ( type TelegramWebhookHandler struct { provider *channelprovider.TelegramProvider telWebhook *telegramchannel.WebhookHandler - // inboxRepo would be injected here for inbox lookup in production + db *gorm.DB } // NewTelegramWebhookHandler creates a Telegram webhook handler for Gin integration. func NewTelegramWebhookHandler( provider *channelprovider.TelegramProvider, telWebhook *telegramchannel.WebhookHandler, + db *gorm.DB, ) *TelegramWebhookHandler { return &TelegramWebhookHandler{ provider: provider, telWebhook: telWebhook, + db: db, } } @@ -123,18 +128,20 @@ func (h *TelegramWebhookHandler) HandleTelegramWebhook(c *gin.Context) { // In production: queries GORM for Inbox with channel_type=telegram matching the bot_token. // Reference: Chatwoot: Inbox.find_by(channel: telegram, bot_token: token) func (h *TelegramWebhookHandler) lookupInbox(botToken string) (*model.Inbox, error) { - // TODO: Implement with GORM repository - // Placeholder — production would use: - // var ch channelmodel.ChannelTelegram - // db.Where("bot_token = ?", botToken).First(&ch) - // var inbox model.Inbox - // db.Where("id = ?", ch.InboxID).First(&inbox) - // - // For now, return a placeholder inbox - inbox := &model.Inbox{ - ChannelType: "telegram", + if h.db == nil { + return nil, fmt.Errorf("telegram webhook database is not configured") } - return inbox, nil + + var channel channelmodel.ChannelTelegram + if err := h.db.Where("bot_token = ?", botToken).First(&channel).Error; err != nil { + return nil, fmt.Errorf("telegram channel not found for bot_token: %w", err) + } + + var inbox model.Inbox + if err := h.db.Where("id = ? AND channel_type = ?", channel.InboxID, "telegram").First(&inbox).Error; err != nil { + return nil, fmt.Errorf("telegram inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) + } + return &inbox, nil } // maskBotToken masks a bot token for safe logging (show first 8 chars only). @@ -143,4 +150,4 @@ func maskBotToken(token string) string { return token[:8] + "..." } return token -} \ No newline at end of file +} diff --git a/internal/handler/webhook/tiktok_webhook.go b/internal/handler/webhook/tiktok_webhook.go index 540e71cf..826fada2 100644 --- a/internal/handler/webhook/tiktok_webhook.go +++ b/internal/handler/webhook/tiktok_webhook.go @@ -5,13 +5,15 @@ package webhook // Reference: Facebook webhook adapter pattern (facebook_webhook.go) import ( + "bytes" "encoding/json" + "fmt" "io" "net/http" - "strconv" tiktokchannel "github.com/gochat/gochat/internal/channel/tiktok" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gin-gonic/gin" @@ -36,18 +38,29 @@ func NewTikTokWebhookHandler(tiktokWebhook *tiktokchannel.WebhookHandler, pipeli // HandleTikTokWebhook processes incoming TikTok webhook HTTP requests. func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) { - inboxIDStr := c.Param("inbox_id") - inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) + body, err := io.ReadAll(c.Request.Body) if err != nil { - applogger.L().Warnf("TikTok webhook: invalid inbox_id %s", inboxIDStr) + applogger.L().Errorf("TikTok webhook: failed to read body: %v", err) + c.JSON(http.StatusOK, gin.H{"status": "ignored"}) + return + } + c.Request.Body.Close() + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + + businessID := c.Param("business_id") + if businessID == "" { + businessID = extractTikTokBusinessID(body) + } + if businessID == "" { + applogger.L().Warn("TikTok webhook: missing business_id in path and payload") c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } // Lookup inbox from database - inbox, err := h.lookupInbox(uint(inboxID)) + inbox, err := h.lookupInboxByBusinessID(businessID) if err != nil { - applogger.L().Warnf("TikTok webhook: inbox lookup failed for id %d: %v", inboxID, err) + applogger.L().Warnf("TikTok webhook: inbox lookup failed for business_id %s: %v", businessID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } @@ -55,14 +68,14 @@ func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) { // Parse the webhook event using the channel-level handler event, err := h.tiktokWebhook.HandleWebhookRequest(c.Request) if err != nil { - applogger.L().Errorf("TikTok webhook: parse request failed for inbox %d: %v", inboxID, err) + applogger.L().Errorf("TikTok webhook: parse request failed for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // Process the event via the pipeline if err := h.pipeline.ProcessUpdate(c.Request.Context(), inbox, *event); err != nil { - applogger.L().Errorf("TikTok webhook: process event failed for inbox %d: %v", inboxID, err) + applogger.L().Errorf("TikTok webhook: process event failed for inbox %d: %v", inbox.ID, err) c.JSON(http.StatusOK, gin.H{"status": "ignored"}) return } @@ -72,13 +85,6 @@ func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) { // HandleTikTokVerification handles TikTok webhook verification (challenge-response). func (h *TikTokWebhookHandler) HandleTikTokVerification(c *gin.Context) { - inboxIDStr := c.Param("inbox_id") - inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox_id"}) - return - } - body, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "failed to read body"}) @@ -94,7 +100,7 @@ func (h *TikTokWebhookHandler) HandleTikTokVerification(c *gin.Context) { } challenge, _ := verifyReq["challenge"].(string) - applogger.L().Infof("TikTok webhook verification: inbox=%d challenge=%s", inboxID, challenge) + applogger.L().Infof("TikTok webhook verification: business_id=%s challenge=%s", c.Param("business_id"), challenge) c.JSON(http.StatusOK, gin.H{"challenge": challenge}) } @@ -108,6 +114,53 @@ func (h *TikTokWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) { return &inbox, nil } +// lookupInboxByBusinessID fetches an Inbox through the TikTok channel record. +func (h *TikTokWebhookHandler) lookupInboxByBusinessID(businessID string) (*model.Inbox, error) { + if h.db == nil { + return nil, fmt.Errorf("tiktok webhook database is not configured") + } + + var channel channelmodel.ChannelTikTok + if err := h.db.Where("tiktok_business_id = ?", businessID).First(&channel).Error; err != nil { + return nil, fmt.Errorf("tiktok channel not found for business_id=%s: %w", businessID, err) + } + + var inbox model.Inbox + if err := h.db.Where("id = ? AND channel_type = ?", channel.InboxID, "tiktok").First(&inbox).Error; err != nil { + return nil, fmt.Errorf("tiktok inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) + } + return &inbox, nil +} + +func extractTikTokBusinessID(body []byte) string { + var payload struct { + BizID string `json:"biz_id"` + BusinessID string `json:"business_id"` + TikTokBusinessID string `json:"tiktok_business_id"` + Data struct { + BizID string `json:"biz_id"` + BusinessID string `json:"business_id"` + TikTokBusinessID string `json:"tiktok_business_id"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return "" + } + for _, candidate := range []string{ + payload.BizID, + payload.BusinessID, + payload.TikTokBusinessID, + payload.Data.BizID, + payload.Data.BusinessID, + payload.Data.TikTokBusinessID, + } { + if candidate != "" { + return candidate + } + } + return "" +} + // parseChannelConfig parses the JSON-encoded ChannelConfig string into a map. func (h *TikTokWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} { if inbox.ChannelConfig == "" { @@ -119,4 +172,4 @@ func (h *TikTokWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string return map[string]interface{}{} } return config -} \ No newline at end of file +} diff --git a/internal/handler/webhook/twilio_webhook.go b/internal/handler/webhook/twilio_webhook.go index ad6484ab..bc549b5b 100644 --- a/internal/handler/webhook/twilio_webhook.go +++ b/internal/handler/webhook/twilio_webhook.go @@ -4,17 +4,19 @@ package webhook // Reference: Facebook webhook adapter pattern (facebook_webhook.go) // // URL patterns: -// /webhooks/twilio/sms/:inbox_id — inbound SMS/MMS -// /webhooks/twilio/status/:inbox_id — delivery status callbacks +// /webhooks/sms/:phone_number — Chatwoot-compatible inbound SMS/MMS +// /webhooks/twilio/sms/:phone_number — legacy inbound SMS/MMS +// /webhooks/twilio/status/:phone_number — delivery status callbacks // // Method: POST (Twilio sends form-encoded data, not JSON) import ( + "fmt" "net/http" - "strconv" twiliochannel "github.com/gochat/gochat/internal/channel/twilio" "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" applogger "github.com/gochat/gochat/pkg/logger" "github.com/gin-gonic/gin" @@ -37,18 +39,17 @@ func NewTwilioWebhookHandler(twilioWebhook *twiliochannel.WebhookHandler, db *go // HandleTwilioInboundSMS processes an incoming Twilio SMS webhook Gin request. func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) { - inboxIDStr := c.Param("inbox_id") - inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) - if err != nil { - applogger.L().Warnf("Twilio webhook: invalid inbox_id %s", inboxIDStr) + phoneNumber := c.Param("phone_number") + if phoneNumber == "" { + applogger.L().Warn("Twilio webhook: missing phone_number in path") c.Data(http.StatusOK, "application/xml", []byte("")) return } // Lookup inbox from database - inbox, err := h.lookupInbox(uint(inboxID)) + inbox, err := h.lookupInboxByPhoneNumber(phoneNumber) if err != nil { - applogger.L().Warnf("Twilio webhook: inbox lookup failed for id %d: %v", inboxID, err) + applogger.L().Warnf("Twilio webhook: inbox lookup failed for phone_number %s: %v", phoneNumber, err) c.Data(http.StatusOK, "application/xml", []byte("")) return } @@ -60,18 +61,17 @@ func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) { // HandleTwilioDeliveryStatus processes a Twilio delivery status callback. func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) { - inboxIDStr := c.Param("inbox_id") - inboxID, err := strconv.ParseUint(inboxIDStr, 10, 32) - if err != nil { - applogger.L().Warnf("Twilio status webhook: invalid inbox_id %s", inboxIDStr) + phoneNumber := c.Param("phone_number") + if phoneNumber == "" { + applogger.L().Warn("Twilio status webhook: missing phone_number in path") c.Status(http.StatusOK) return } // Lookup inbox from database - inbox, err := h.lookupInbox(uint(inboxID)) + inbox, err := h.lookupInboxByPhoneNumber(phoneNumber) if err != nil { - applogger.L().Warnf("Twilio status webhook: inbox lookup failed for id %d: %v", inboxID, err) + applogger.L().Warnf("Twilio status webhook: inbox lookup failed for phone_number %s: %v", phoneNumber, err) c.Status(http.StatusOK) return } @@ -87,4 +87,23 @@ func (h *TwilioWebhookHandler) lookupInbox(inboxID uint) (*model.Inbox, error) { return nil, err } return &inbox, nil -} \ No newline at end of file +} + +// lookupInboxByPhoneNumber fetches an Inbox through the Twilio SMS channel record. +// Chatwoot exposes /webhooks/sms/:phone_number and routes by the phone number. +func (h *TwilioWebhookHandler) lookupInboxByPhoneNumber(phoneNumber string) (*model.Inbox, error) { + if h.db == nil { + return nil, fmt.Errorf("twilio webhook database is not configured") + } + + var channel channelmodel.ChannelTwilioSMS + if err := h.db.Where("phone_number = ?", phoneNumber).First(&channel).Error; err != nil { + return nil, fmt.Errorf("twilio sms channel not found for phone_number=%s: %w", phoneNumber, err) + } + + var inbox model.Inbox + if err := h.db.Where("id = ? AND channel_type IN ?", channel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil { + return nil, fmt.Errorf("twilio inbox not found for channel inbox_id=%d: %w", channel.InboxID, err) + } + return &inbox, nil +} diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go new file mode 100644 index 00000000..8e067494 --- /dev/null +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -0,0 +1,142 @@ +package webhook + +import ( + "testing" + + "github.com/gochat/gochat/internal/model" + channelmodel "github.com/gochat/gochat/internal/model/channel" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func newWebhookLookupTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate( + &model.Inbox{}, + &channelmodel.ChannelTelegram{}, + &channelmodel.ChannelLINE{}, + &channelmodel.ChannelTwilioSMS{}, + &channelmodel.ChannelTikTok{}, + ); err != nil { + t.Fatalf("migrate webhook lookup models: %v", err) + } + return db +} + +func seedWebhookInbox(t *testing.T, db *gorm.DB, channelType string) model.Inbox { + t.Helper() + + inbox := model.Inbox{ + AccountID: 1, + Name: channelType + " inbox", + ChannelType: channelType, + ChannelID: 1, + Enabled: true, + } + if err := db.Create(&inbox).Error; err != nil { + t.Fatalf("create inbox: %v", err) + } + return inbox +} + +func TestTelegramWebhookLookupInboxByBotToken(t *testing.T) { + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "telegram") + channel := channelmodel.ChannelTelegram{ + AccountID: 1, + InboxID: inbox.ID, + BotToken: "123:secret-token", + BotName: "support_bot", + } + if err := db.Create(&channel).Error; err != nil { + t.Fatalf("create telegram channel: %v", err) + } + + h := NewTelegramWebhookHandler(nil, nil, db) + found, err := h.lookupInbox("123:secret-token") + if err != nil { + t.Fatalf("lookup inbox: %v", err) + } + if found.ID != inbox.ID || found.ChannelType != "telegram" { + t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType) + } +} + +func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) { + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "line") + channel := channelmodel.ChannelLINE{ + AccountID: 1, + InboxID: inbox.ID, + ChannelID: "line-channel-1", + Name: "LINE OA", + } + if err := db.Create(&channel).Error; err != nil { + t.Fatalf("create line channel: %v", err) + } + + h := NewLineWebhookHandler(nil, nil, nil, db) + found, err := h.lookupInboxByLineChannelID("line-channel-1") + if err != nil { + t.Fatalf("lookup inbox: %v", err) + } + if found.ID != inbox.ID || found.ChannelType != "line" { + t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType) + } +} + +func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) { + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "twilio_sms") + channel := channelmodel.ChannelTwilioSMS{ + AccountID: 1, + InboxID: inbox.ID, + AccountSID: "AC123", + PhoneNumber: "+15551234567", + MessagingServiceSID: "MG123", + } + if err := db.Create(&channel).Error; err != nil { + t.Fatalf("create twilio channel: %v", err) + } + + h := NewTwilioWebhookHandler(nil, db) + found, err := h.lookupInboxByPhoneNumber("+15551234567") + if err != nil { + t.Fatalf("lookup inbox: %v", err) + } + if found.ID != inbox.ID || found.ChannelType != "twilio_sms" { + t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType) + } +} + +func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) { + db := newWebhookLookupTestDB(t) + inbox := seedWebhookInbox(t, db, "tiktok") + channel := channelmodel.ChannelTikTok{ + AccountID: 1, + InboxID: inbox.ID, + TikTokBusinessID: "biz-123", + WebhookVerifyToken: "verify-token", + } + if err := db.Create(&channel).Error; err != nil { + t.Fatalf("create tiktok channel: %v", err) + } + + h := NewTikTokWebhookHandler(nil, nil, db) + found, err := h.lookupInboxByBusinessID("biz-123") + if err != nil { + t.Fatalf("lookup inbox: %v", err) + } + if found.ID != inbox.ID || found.ChannelType != "tiktok" { + t.Fatalf("unexpected inbox: id=%d type=%s", found.ID, found.ChannelType) + } + + if got := extractTikTokBusinessID([]byte(`{"data":{"business_id":"biz-123"}}`)); got != "biz-123" { + t.Fatalf("unexpected extracted business id: %s", got) + } +} diff --git a/internal/middleware/webhook_auth.go b/internal/middleware/webhook_auth.go index db2dd70b..32ef2876 100644 --- a/internal/middleware/webhook_auth.go +++ b/internal/middleware/webhook_auth.go @@ -13,6 +13,11 @@ import ( // Reference: P2E §4 — webhook authentication per channel type func WebhookAuth(registry *auth.WebhookTokenRegistry) gin.HandlerFunc { return func(c *gin.Context) { + if registry == nil { + response.AbortWithStatusError(c, http.StatusUnauthorized, response.ErrUnauthorized, "Webhook registry not configured") + return + } + channelType := c.Param("channel_type") identifier := c.Param("identifier") @@ -23,4 +28,4 @@ func WebhookAuth(registry *auth.WebhookTokenRegistry) gin.HandlerFunc { } c.Next() } -} \ No newline at end of file +} diff --git a/internal/model/channel/tiktok.go b/internal/model/channel/tiktok.go index ee8dc9b0..e7c737f1 100644 --- a/internal/model/channel/tiktok.go +++ b/internal/model/channel/tiktok.go @@ -31,12 +31,12 @@ type ChannelTikTok struct { InboxID uint `gorm:"index" json:"inbox_id,omitempty"` // TikTok Business account ID (obtained after OAuth authorization) - TikTokBusinessID string `gorm:"type:varchar(255);not null" json:"tiktok_business_id"` + TikTokBusinessID string `gorm:"column:tiktok_business_id;type:varchar(255);not null" json:"tiktok_business_id"` // OAuth2 tokens (obtained via BuildAuthURL → ExchangeToken flow) - AccessToken string `gorm:"type:varchar(512)" json:"access_token,omitempty"` - RefreshToken string `gorm:"type:varchar(512)" json:"refresh_token,omitempty"` - TokenExpiresAt time.Time `gorm:"" json:"token_expires_at,omitempty"` + AccessToken string `gorm:"type:varchar(512)" json:"access_token,omitempty"` + RefreshToken string `gorm:"type:varchar(512)" json:"refresh_token,omitempty"` + TokenExpiresAt time.Time `gorm:"" json:"token_expires_at,omitempty"` // Webhook verification token (set during channel creation for webhook setup) WebhookVerifyToken string `gorm:"type:varchar(255)" json:"webhook_verify_token,omitempty"` @@ -63,4 +63,4 @@ func (c ChannelTikTok) GetChannelBase() ChannelBase { func (c ChannelTikTok) GetChannelType() string { return "tiktok" } // TableName returns the GORM table name for ChannelTikTok. -func (ChannelTikTok) TableName() string { return "channel_tiktoks" } \ No newline at end of file +func (ChannelTikTok) TableName() string { return "channel_tiktoks" } diff --git a/internal/router/router.go b/internal/router/router.go index e65ff2dc..0ff62951 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -281,10 +281,10 @@ func RegisterRoutes( apiV2.Use(middleware.AuthMiddleware(jwtCfg)) registerV2Routes(apiV2, handlers) - // Webhook callback routes — channel-specific auth - // Reference: Chatwoot webhook routes for FB/WhatsApp/Telegram + // Webhook callback routes. + // Reference: Chatwoot routes provider-specific public webhook paths and lets + // each provider handler verify its own token/signature contract. webhookGroup := engine.Group("/webhooks") - webhookGroup.Use(middleware.WebhookAuth(webhookRegistry)) // Facebook/Instagram webhook — Meta Business Suite combined endpoint // GET: webhook verification (hub.mode=subscribe, hub.verify_token, hub.challenge) @@ -298,59 +298,140 @@ func RegisterRoutes( // Telegram webhook — bot token in URL path for routing // Reference: Chatwoot routes at /webhooks/telegram/:bot_token - if handlers.TelegramWebhook != nil { - tgGroup := webhookGroup.Group("/telegram") - tgGroup.POST("/:bot_token", handlers.TelegramWebhook.HandleTelegramWebhook) - } + tgGroup := webhookGroup.Group("/telegram") + tgGroup.POST("/:bot_token", func(c *gin.Context) { + if handlers == nil || handlers.TelegramWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TelegramWebhook.HandleTelegramWebhook(c) + }) // WhatsApp webhook — Cloud API webhook verification + incoming events // GET: webhook verification (hub.mode=subscribe, hub.verify_token, hub.challenge) // POST: incoming message/event processing (X-Hub-Signature-256 validated) // Reference: Chatwoot routes at /webhooks/whatsapp/:phone_number // Reference: WhatsApp Cloud API https://developers.facebook.com/docs/whatsapp/cloud-api/get-started#verify-webhook - if handlers.WhatsAppWebhook != nil { - waGroup := webhookGroup.Group("/whatsapp") - waGroup.GET("/:phone_number_id", handlers.WhatsAppWebhook.HandleWhatsAppVerification) - waGroup.POST("/:phone_number_id", handlers.WhatsAppWebhook.HandleWhatsAppWebhook) - } + waGroup := webhookGroup.Group("/whatsapp") + waGroup.GET("/:phone_number", func(c *gin.Context) { + if handlers == nil || handlers.WhatsAppWebhook == nil { + chatwootParityStub(c) + return + } + handlers.WhatsAppWebhook.HandleWhatsAppVerification(c) + }) + waGroup.POST("/:phone_number", func(c *gin.Context) { + if handlers == nil || handlers.WhatsAppWebhook == nil { + chatwootParityStub(c) + return + } + handlers.WhatsAppWebhook.HandleWhatsAppWebhook(c) + }) // TikTok webhook — Business API event notifications // GET: webhook URL verification request // POST: incoming message/event processing (X-TikTok-Signature validated) // Reference: TikTok Business API https://business-api.tiktok.com/portal/docs?id=1739584855420928 - if handlers.TikTokWebhook != nil { - ttGroup := webhookGroup.Group("/tiktok") - ttGroup.GET("/:business_id", handlers.TikTokWebhook.HandleTikTokVerification) - ttGroup.POST("/:business_id", handlers.TikTokWebhook.HandleTikTokWebhook) - } + ttGroup := webhookGroup.Group("/tiktok") + ttGroup.POST("", func(c *gin.Context) { + if handlers == nil || handlers.TikTokWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TikTokWebhook.HandleTikTokWebhook(c) + }) + ttGroup.GET("/:business_id", func(c *gin.Context) { + if handlers == nil || handlers.TikTokWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TikTokWebhook.HandleTikTokVerification(c) + }) + ttGroup.POST("/:business_id", func(c *gin.Context) { + if handlers == nil || handlers.TikTokWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TikTokWebhook.HandleTikTokWebhook(c) + }) // LINE webhook — Messaging API event notifications // POST: incoming message/event processing (X-Line-Signature HMAC-SHA256 validated) // Reference: LINE Messaging API https://developers.line.biz/en/docs/messaging-api/receiving-messages/ - if handlers.LineWebhook != nil { - lineGroup := webhookGroup.Group("/line") - lineGroup.POST("/:channel_id", handlers.LineWebhook.HandleLineWebhook) - } + lineGroup := webhookGroup.Group("/line") + lineGroup.POST("/:line_channel_id", func(c *gin.Context) { + if handlers == nil || handlers.LineWebhook == nil { + chatwootParityStub(c) + return + } + handlers.LineWebhook.HandleLineWebhook(c) + }) // Twilio SMS webhook — inbound SMS/MMS + delivery status callbacks // POST /sms/:phone_number: inbound SMS/MMS messages (form-encoded body, TwiML response) // POST /status/:phone_number: delivery status callbacks (delivered/undelivered/failed) // Reference: Twilio SMS API https://www.twilio.com/docs/sms/api/message-resource - if handlers.TwilioWebhook != nil { - twilioGroup := webhookGroup.Group("/twilio") - twilioGroup.POST("/sms/:phone_number", handlers.TwilioWebhook.HandleTwilioInboundSMS) - twilioGroup.POST("/status/:phone_number", handlers.TwilioWebhook.HandleTwilioDeliveryStatus) - } + webhookGroup.POST("/sms/:phone_number", func(c *gin.Context) { + if handlers == nil || handlers.TwilioWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TwilioWebhook.HandleTwilioInboundSMS(c) + }) + twilioGroup := webhookGroup.Group("/twilio") + twilioGroup.POST("/sms/:phone_number", func(c *gin.Context) { + if handlers == nil || handlers.TwilioWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TwilioWebhook.HandleTwilioInboundSMS(c) + }) + twilioGroup.POST("/status/:phone_number", func(c *gin.Context) { + if handlers == nil || handlers.TwilioWebhook == nil { + chatwootParityStub(c) + return + } + handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c) + }) // Twitter webhook — Account Activity API CRC validation + event processing // GET: CRC challenge response (crc_token query param) // POST: incoming DM/event processing // Reference: Twitter API v2 Account Activity API https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/account-activity-api - if handlers.TwitterChannel != nil { - twWebhookGroup := webhookGroup.Group("/twitter") - twWebhookGroup.GET("/webhook", handlers.TwitterChannel.WebhookCRC) - twWebhookGroup.POST("/webhook", handlers.TwitterChannel.WebhookEvent) - } + twWebhookGroup := webhookGroup.Group("/twitter") + twWebhookGroup.GET("", func(c *gin.Context) { + if handlers == nil || handlers.TwitterChannel == nil { + chatwootParityStub(c) + return + } + handlers.TwitterChannel.WebhookCRC(c) + }) + twWebhookGroup.POST("", func(c *gin.Context) { + if handlers == nil || handlers.TwitterChannel == nil { + chatwootParityStub(c) + return + } + handlers.TwitterChannel.WebhookEvent(c) + }) + // Legacy GoChat aliases kept for already configured Twitter webhooks. + twWebhookGroup.GET("/webhook", func(c *gin.Context) { + if handlers == nil || handlers.TwitterChannel == nil { + chatwootParityStub(c) + return + } + handlers.TwitterChannel.WebhookCRC(c) + }) + twWebhookGroup.POST("/webhook", func(c *gin.Context) { + if handlers == nil || handlers.TwitterChannel == nil { + chatwootParityStub(c) + return + } + handlers.TwitterChannel.WebhookEvent(c) + }) + + webhookGroup.GET("/instagram", chatwootParityStub) + webhookGroup.POST("/instagram", chatwootParityStub) + webhookGroup.POST("/shopify", chatwootParityStub) // Microsoft webhook — Graph API subscription validation + notifications // POST: validation request (returns validationToken) + change notifications @@ -371,8 +452,8 @@ func RegisterRoutes( emailWebhookGroup.GET("/:inbox_id/verification", handlers.EmailWebhook.HandleEmailVerification) } - // Generic webhook fallback for other channels (WebWidget, etc.) - webhookGroup.Any("/:channel_type/:identifier", webhookStub) + // Generic webhook success fallback was removed for P6.7: provider paths above + // should either process/verify the callback or return explicit not-implemented. // WebSocket endpoints (ref: Chatwoot ActionCable mount at /cable) // P9: real-time communication — WebSocket upgrade with JWT/pubsub_token auth diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 2570ea58..43591a59 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -38,6 +38,15 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) { "PUT /public/api/v1/inboxes/:inbox_id/contacts/:contact_id/conversations/:conversation_id/messages/:message_id", "GET /api/v2/accounts/:account_id/reports/summary", "GET /api/v2/accounts/:account_id/live_reports/grouped_conversation_metrics", + "GET /webhooks/twitter", + "POST /webhooks/twitter", + "POST /webhooks/telegram/:bot_token", + "POST /webhooks/line/:line_channel_id", + "POST /webhooks/sms/:phone_number", + "GET /webhooks/whatsapp/:phone_number", + "POST /webhooks/whatsapp/:phone_number", + "POST /webhooks/tiktok", + "POST /webhooks/shopify", } for _, key := range expected {