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 {