feat(webhook): align chatwoot ingress routes

This commit is contained in:
2026-06-04 23:20:58 +08:00
parent 6aa62c6d7b
commit 9e3f561bed
12 changed files with 467 additions and 120 deletions
+35 -13
View File
@@ -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
}
}
+20 -13
View File
@@ -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
}
}
+70 -17
View File
@@ -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
}
}
+35 -16
View File
@@ -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)
}
}