feat(webhook): align chatwoot ingress routes
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<Response></Response>"))
|
||||
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("<Response></Response>"))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
func (ChannelTikTok) TableName() string { return "channel_tiktoks" }
|
||||
|
||||
+114
-33
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user