feat(webhook): persist incoming provider messages

This commit is contained in:
2026-06-04 23:54:07 +08:00
parent b3865f9084
commit 5eb726b124
14 changed files with 535 additions and 84 deletions
+22 -7
View File
@@ -47,6 +47,7 @@ type FacebookWebhookHandler struct {
igProvider *fbchannel.InstagramProvider
webhookParser *fbchannel.WebhookParser
db *gorm.DB
persister *IncomingPersister
}
// NewFacebookWebhookHandler creates a Facebook/Instagram webhook handler for Gin integration.
@@ -60,6 +61,7 @@ func NewFacebookWebhookHandler(
igProvider: igProvider,
webhookParser: fbchannel.NewWebhookParser(),
db: db,
persister: NewIncomingPersister(db),
}
}
@@ -218,9 +220,12 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
continue
}
if commentMsg != nil {
applogger.L().Infof("Facebook webhook: IG comment processed (inbox_id=%d, source_id=%s, type=%s)",
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, commentMsg); persistErr != nil {
applogger.L().Errorf("Facebook webhook: IG comment persist failed (source_id=%s): %v", commentMsg.SourceID, persistErr)
continue
}
applogger.L().Infof("Facebook webhook: IG comment persisted (inbox_id=%d, source_id=%s, type=%s)",
commentMsg.InboxID, commentMsg.SourceID, event.EventType)
// TODO: Push to message broker/dispatcher for persistence + notification
}
continue
}
@@ -257,10 +262,12 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
}
if incomingMsg != nil {
applogger.L().Infof("Facebook webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)",
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("Facebook webhook: persist message failed (source_id=%s): %v", incomingMsg.SourceID, persistErr)
continue
}
applogger.L().Infof("Facebook webhook: message persisted (inbox_id=%d, source_id=%s, type=%s)",
incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
// TODO: Push to message broker/dispatcher for persistence + notification
// Reference: Chatwoot pushes to IncomingMessageService → Conversation + Message creation
}
}
@@ -430,7 +437,11 @@ func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *mo
applogger.L().Errorf("Instagram webhook: comment processing failed: %v", err)
}
if commentMsg != nil {
applogger.L().Infof("Instagram webhook: comment processed (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType)
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, commentMsg); persistErr != nil {
applogger.L().Errorf("Instagram webhook: comment persist failed (source_id=%s): %v", commentMsg.SourceID, persistErr)
return
}
applogger.L().Infof("Instagram webhook: comment persisted (inbox_id=%d, source_id=%s, type=%s)", commentMsg.InboxID, commentMsg.SourceID, event.EventType)
}
return
}
@@ -444,7 +455,11 @@ func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *mo
return
}
if incomingMsg != nil {
applogger.L().Infof("Instagram webhook: message extracted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("Instagram webhook: persist message failed (source_id=%s): %v", incomingMsg.SourceID, persistErr)
return
}
applogger.L().Infof("Instagram webhook: message persisted (inbox_id=%d, source_id=%s, type=%s)", incomingMsg.InboxID, incomingMsg.SourceID, event.EventType)
}
}
@@ -0,0 +1,254 @@
package webhook
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/model"
)
// IncomingPersister is the durable boundary after provider-specific webhook parsing.
// Reference: Chatwoot IncomingMessageService creates ContactInbox, Conversation, and Message.
type IncomingPersister struct {
db *gorm.DB
}
type IncomingPersistResult struct {
Contact *model.Contact
ContactInbox *model.ContactInbox
Conversation *model.Conversation
Message *model.Message
Duplicate bool
}
func NewIncomingPersister(db *gorm.DB) *IncomingPersister {
if db == nil {
return nil
}
return &IncomingPersister{db: db}
}
func (p *IncomingPersister) PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (*IncomingPersistResult, error) {
if p == nil || p.db == nil || inbox == nil || msg == nil {
return nil, nil
}
if msg.SourceID == "" {
return nil, fmt.Errorf("incoming message missing source_id")
}
senderID := msg.SenderID
if senderID == "" {
senderID = msg.ConversationID
}
if senderID == "" {
return nil, fmt.Errorf("incoming message missing sender_id")
}
if msg.Content == "" && len(msg.Attachments) == 0 {
return nil, fmt.Errorf("incoming message has no content or attachments")
}
var result IncomingPersistResult
err := p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var existingMessage model.Message
if err := tx.Where("inbox_id = ? AND source_id = ?", inbox.ID, msg.SourceID).First(&existingMessage).Error; err == nil {
result.Message = &existingMessage
result.Duplicate = true
return nil
} else if err != gorm.ErrRecordNotFound {
return err
}
contact, contactInbox, err := p.resolveOrCreateContactInbox(ctx, tx, inbox, msg, senderID)
if err != nil {
return err
}
result.Contact = contact
result.ContactInbox = contactInbox
conversation, err := p.resolveOrCreateConversation(ctx, tx, inbox, contact, contactInbox, msg)
if err != nil {
return err
}
result.Conversation = conversation
message, err := p.createMessage(ctx, tx, inbox, conversation, contact, msg)
if err != nil {
return err
}
result.Message = message
return nil
})
if err != nil {
return nil, err
}
return &result, nil
}
func (p *IncomingPersister) resolveOrCreateContactInbox(ctx context.Context, tx *gorm.DB, inbox *model.Inbox, msg *channel.IncomingMessage, senderID string) (*model.Contact, *model.ContactInbox, error) {
var contactInbox model.ContactInbox
if err := tx.WithContext(ctx).Preload("Contact").Where("inbox_id = ? AND source_id = ?", inbox.ID, senderID).First(&contactInbox).Error; err == nil {
contact := contactInbox.Contact
updates := map[string]interface{}{}
if msg.SenderName != "" && contact.Name != msg.SenderName {
updates["name"] = msg.SenderName
}
if len(updates) > 0 {
if err := tx.WithContext(ctx).Model(&contact).Updates(updates).Error; err != nil {
return nil, nil, err
}
if err := tx.WithContext(ctx).First(&contact, contact.ID).Error; err != nil {
return nil, nil, err
}
}
return &contact, &contactInbox, nil
} else if err != gorm.ErrRecordNotFound {
return nil, nil, err
}
name := msg.SenderName
if name == "" {
name = senderID
}
attrs := mergeChannelConfig(msg.SenderExtra, map[string]interface{}{
"source_id": senderID,
"channel_type": string(msg.ChannelType),
})
contact := model.Contact{
AccountID: inbox.AccountID,
Name: name,
Identifier: senderID,
SourceID: string(msg.ChannelType),
AdditionalAttributes: mustJSON(attrs),
CustomAttributes: datatypes.JSON("{}"),
}
if err := tx.WithContext(ctx).Create(&contact).Error; err != nil {
return nil, nil, err
}
contactInbox = model.ContactInbox{
ContactID: contact.ID,
InboxID: inbox.ID,
SourceID: senderID,
PubsubToken: uuid.NewString(),
}
if err := tx.WithContext(ctx).Create(&contactInbox).Error; err != nil {
return nil, nil, err
}
return &contact, &contactInbox, nil
}
func (p *IncomingPersister) resolveOrCreateConversation(ctx context.Context, tx *gorm.DB, inbox *model.Inbox, contact *model.Contact, contactInbox *model.ContactInbox, msg *channel.IncomingMessage) (*model.Conversation, error) {
var conversation model.Conversation
if err := tx.WithContext(ctx).
Where("account_id = ? AND inbox_id = ? AND contact_id = ? AND status = ?", inbox.AccountID, inbox.ID, contact.ID, model.ConversationStatusOpen).
Order("id DESC").First(&conversation).Error; err == nil {
now := time.Now().Unix()
_ = tx.WithContext(ctx).Model(&conversation).Updates(map[string]interface{}{
"last_activity_at": now,
"last_message_at": now,
}).Error
return &conversation, nil
} else if err != gorm.ErrRecordNotFound {
return nil, err
}
now := time.Now().Unix()
channelType := inbox.ChannelType
if msg.ChannelType != "" {
channelType = string(msg.ChannelType)
}
conversation = model.Conversation{
AccountID: inbox.AccountID,
InboxID: inbox.ID,
ContactID: contact.ID,
ContactInboxID: &contactInbox.ID,
Status: string(model.ConversationStatusOpen),
Priority: "none",
ChannelType: channelType,
Channel: inbox.ChannelType,
LastActivityAt: &now,
LastMessageAt: &now,
LastNonSysMsgAt: &now,
AdditionalAttributes: mustJSON(mergeChannelConfig(msg.ConversationExtra, map[string]interface{}{"external_conversation_id": msg.ConversationID})),
CustomAttributes: datatypes.JSON("{}"),
}
if err := tx.WithContext(ctx).Create(&conversation).Error; err != nil {
return nil, err
}
return &conversation, nil
}
func (p *IncomingPersister) createMessage(ctx context.Context, tx *gorm.DB, inbox *model.Inbox, conversation *model.Conversation, contact *model.Contact, msg *channel.IncomingMessage) (*model.Message, error) {
contentAttrs := map[string]interface{}{}
if len(msg.Attachments) > 0 {
contentAttrs["attachments"] = msg.Attachments
}
if msg.ReplyToID != "" {
contentAttrs["in_reply_to"] = msg.ReplyToID
}
message := model.Message{
ConversationID: conversation.ID,
AccountID: inbox.AccountID,
InboxID: inbox.ID,
SenderID: &contact.ID,
SenderType: string(model.SenderTypeContact),
Content: msg.Content,
ContentType: mapIncomingContentType(msg.ContentType),
Status: string(model.MessageStatusSent),
MessageType: string(model.MessageTypeIncoming),
SourceID: msg.SourceID,
ContentAttributes: mustJSON(contentAttrs),
AdditionalAttributes: mustJSON(msg.Extra),
}
if err := tx.WithContext(ctx).Create(&message).Error; err != nil {
return nil, err
}
return &message, nil
}
func mapIncomingContentType(contentType channel.ContentType) string {
switch contentType {
case channel.ContentImage:
return string(model.MessageContentTypeImage)
case channel.ContentAudio:
return string(model.MessageContentTypeAudio)
case channel.ContentVideo:
return string(model.MessageContentTypeVideo)
case channel.ContentFile:
return string(model.MessageContentTypeFile)
case channel.ContentLocation:
return string(model.MessageContentTypeLocation)
default:
return string(model.MessageContentTypeText)
}
}
func mergeChannelConfig(base channel.ChannelConfig, extra map[string]interface{}) map[string]interface{} {
merged := map[string]interface{}{}
for k, v := range base {
merged[k] = v
}
for k, v := range extra {
if v != "" && v != nil {
merged[k] = v
}
}
return merged
}
func mustJSON(value interface{}) datatypes.JSON {
if value == nil {
return datatypes.JSON("{}")
}
data, err := json.Marshal(value)
if err != nil || len(data) == 0 {
return datatypes.JSON("{}")
}
return datatypes.JSON(data)
}
+7 -2
View File
@@ -27,6 +27,7 @@ type LineWebhookHandler struct {
pipeline *linechannel.IncomingProcessor
service *linechannel.LineService
db *gorm.DB
persister *IncomingPersister
}
// NewLineWebhookHandler creates a LINE webhook handler for Gin integration.
@@ -36,6 +37,7 @@ func NewLineWebhookHandler(lineWebhook *linechannel.WebhookHandler, pipeline *li
pipeline: pipeline,
service: service,
db: db,
persister: NewIncomingPersister(db),
}
}
@@ -100,8 +102,11 @@ func (h *LineWebhookHandler) HandleLineWebhook(c *gin.Context) {
continue
}
if incomingMsg != nil {
applogger.L().Debugf("LINE webhook: processed event type=%s sender=%s",
event.Type, incomingMsg.SenderID)
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("LINE webhook: persist event failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr)
continue
}
applogger.L().Debugf("LINE webhook: persisted event type=%s sender=%s", event.Type, incomingMsg.SenderID)
}
}
+8 -3
View File
@@ -36,6 +36,7 @@ type TelegramWebhookHandler struct {
provider *channelprovider.TelegramProvider
telWebhook *telegramchannel.WebhookHandler
db *gorm.DB
persister *IncomingPersister
}
// NewTelegramWebhookHandler creates a Telegram webhook handler for Gin integration.
@@ -48,6 +49,7 @@ func NewTelegramWebhookHandler(
provider: provider,
telWebhook: telWebhook,
db: db,
persister: NewIncomingPersister(db),
}
}
@@ -115,9 +117,12 @@ func (h *TelegramWebhookHandler) HandleTelegramWebhook(c *gin.Context) {
if err != nil {
applogger.L().Errorf("Telegram webhook: message processing failed (update_id=%d): %v", update.UpdateID, err)
} else if incomingMsg != nil {
applogger.L().Infof("Telegram webhook: message processed (inbox_id=%d, source_id=%s)",
incomingMsg.InboxID, incomingMsg.SourceID)
// TODO: Push to message broker/dispatcher for persistence + notification
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("Telegram webhook: persist message failed (update_id=%d source_id=%s): %v", update.UpdateID, incomingMsg.SourceID, persistErr)
} else {
applogger.L().Infof("Telegram webhook: message persisted (inbox_id=%d, source_id=%s)",
incomingMsg.InboxID, incomingMsg.SourceID)
}
}
// Always return 200 OK to Telegram — it retries if not 200
+9 -1
View File
@@ -25,6 +25,7 @@ type TikTokWebhookHandler struct {
tiktokWebhook *tiktokchannel.WebhookHandler
pipeline *tiktokchannel.IncomingProcessor
db *gorm.DB
persister *IncomingPersister
}
// NewTikTokWebhookHandler creates a Gin-compatible TikTok webhook handler.
@@ -33,6 +34,7 @@ func NewTikTokWebhookHandler(tiktokWebhook *tiktokchannel.WebhookHandler, pipeli
tiktokWebhook: tiktokWebhook,
pipeline: pipeline,
db: db,
persister: NewIncomingPersister(db),
}
}
@@ -74,11 +76,17 @@ func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) {
}
// Process the event via the pipeline
if err := h.pipeline.ProcessUpdate(c.Request.Context(), inbox, *event); err != nil {
incomingMsg, err := h.pipeline.ProcessUpdate(c.Request.Context(), inbox, *event)
if err != nil {
applogger.L().Errorf("TikTok webhook: process event failed for inbox %d: %v", inbox.ID, err)
c.JSON(http.StatusOK, gin.H{"status": "ignored"})
return
}
if incomingMsg != nil {
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("TikTok webhook: persist event failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr)
}
}
c.JSON(http.StatusOK, gin.H{"status": "processed"})
}
+14 -3
View File
@@ -27,6 +27,7 @@ import (
type TwilioWebhookHandler struct {
twilioWebhook *twiliochannel.WebhookHandler
db *gorm.DB
persister *IncomingPersister
}
// NewTwilioWebhookHandler creates a Twilio SMS webhook handler for Gin integration.
@@ -34,6 +35,7 @@ func NewTwilioWebhookHandler(twilioWebhook *twiliochannel.WebhookHandler, db *go
return &TwilioWebhookHandler{
twilioWebhook: twilioWebhook,
db: db,
persister: NewIncomingPersister(db),
}
}
@@ -54,9 +56,18 @@ func (h *TwilioWebhookHandler) HandleTwilioInboundSMS(c *gin.Context) {
return
}
// Dispatch to the channel-level webhook handler
// Twilio expects a TwiML XML response, not JSON
h.twilioWebhook.HandleInboundSMS(c.Writer, c.Request, inbox)
incomingMsg, err := h.twilioWebhook.ProcessInboundSMS(c.Request, inbox)
if err != nil {
applogger.L().Errorf("Twilio webhook: process inbound SMS failed for inbox %d: %v", inbox.ID, err)
c.Data(http.StatusOK, "application/xml", []byte("<Response></Response>"))
return
}
if incomingMsg != nil {
if _, persistErr := h.persister.PersistIncoming(c.Request.Context(), inbox, incomingMsg); persistErr != nil {
applogger.L().Errorf("Twilio webhook: persist inbound SMS failed for inbox %d source_id=%s: %v", inbox.ID, incomingMsg.SourceID, persistErr)
}
}
c.Data(http.StatusOK, "application/xml", []byte("<Response></Response>"))
}
// HandleTwilioDeliveryStatus processes a Twilio delivery status callback.
+100 -1
View File
@@ -9,9 +9,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/channel"
channelprovider "github.com/gochat/gochat/internal/channel/provider"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"gorm.io/driver/sqlite"
@@ -21,12 +24,17 @@ import (
func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
dsn := "file:" + strings.NewReplacer("/", "_", " ", "_", ":", "_").Replace(t.Name()) + "?mode=memory&cache=shared"
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&model.Inbox{},
&model.Contact{},
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&channelmodel.ChannelTelegram{},
&channelmodel.ChannelLINE{},
&channelmodel.ChannelTwilioSMS{},
@@ -39,6 +47,61 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
return db
}
func TestIncomingPersisterCreatesConversationMessageAndDedupes(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "telegram")
persister := NewIncomingPersister(db)
msg := &channel.IncomingMessage{
ChannelType: channel.ChannelTelegram,
SourceID: "tg-msg-1",
SenderID: "tg-user-1",
SenderName: "Ada Lovelace",
SenderType: channel.SenderContact,
Content: "hello",
ContentType: channel.ContentText,
InboxID: inbox.ID,
AccountID: inbox.AccountID,
}
result, err := persister.PersistIncoming(t.Context(), &inbox, msg)
if err != nil {
t.Fatalf("persist incoming: %v", err)
}
if result.Contact == nil || result.ContactInbox == nil || result.Conversation == nil || result.Message == nil {
t.Fatalf("expected full persistence result: %#v", result)
}
if result.Message.Content != "hello" || result.Message.SourceID != "tg-msg-1" {
t.Fatalf("unexpected message: %#v", result.Message)
}
duplicate, err := persister.PersistIncoming(t.Context(), &inbox, msg)
if err != nil {
t.Fatalf("persist duplicate: %v", err)
}
if duplicate == nil || !duplicate.Duplicate {
t.Fatalf("expected duplicate result, got %#v", duplicate)
}
msg.SourceID = "tg-msg-2"
msg.Content = "second"
second, err := persister.PersistIncoming(t.Context(), &inbox, msg)
if err != nil {
t.Fatalf("persist second: %v", err)
}
if second.Contact.ID != result.Contact.ID || second.Conversation.ID != result.Conversation.ID {
t.Fatalf("expected contact/conversation reuse: first=%#v second=%#v", result, second)
}
var messageCount int64
if err := db.Model(&model.Message{}).Where("inbox_id = ?", inbox.ID).Count(&messageCount).Error; err != nil {
t.Fatalf("count messages: %v", err)
}
if messageCount != 2 {
t.Fatalf("expected 2 persisted messages after duplicate skip, got %d", messageCount)
}
}
func shopifyHMAC(secret string, body []byte) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
@@ -90,6 +153,42 @@ func TestTelegramWebhookLookupInboxByBotToken(t *testing.T) {
}
}
func TestTelegramWebhookPersistsIncomingMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "telegram")
channelRecord := channelmodel.ChannelTelegram{
AccountID: 1,
InboxID: inbox.ID,
BotToken: "123:secret-token",
BotName: "support_bot",
}
if err := db.Create(&channelRecord).Error; err != nil {
t.Fatalf("create telegram channel: %v", err)
}
body := []byte(`{"update_id":1001,"message":{"message_id":2002,"from":{"id":3003,"first_name":"Ada","last_name":"Lovelace","username":"ada"},"chat":{"id":3003,"type":"private"},"date":1710000000,"text":"hello telegram"}}`)
h := NewTelegramWebhookHandler(channelprovider.NewTelegramProvider(), nil, db)
r := gin.New()
r.POST("/webhooks/telegram/:bot_token", h.HandleTelegramWebhook)
req := httptest.NewRequest(http.MethodPost, "/webhooks/telegram/123:secret-token", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var message model.Message
if err := db.Where("inbox_id = ? AND source_id = ?", inbox.ID, "2002").First(&message).Error; err != nil {
t.Fatalf("expected telegram message persisted: %v", err)
}
if message.Content != "hello telegram" || message.MessageType != string(model.MessageTypeIncoming) {
t.Fatalf("unexpected message: %#v", message)
}
}
func TestLineWebhookLookupInboxByLineChannelID(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "line")
+42 -23
View File
@@ -5,9 +5,14 @@
package webhook
import (
"github.com/gin-gonic/gin"
"context"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/gochat/gochat/internal/channel"
"github.com/gochat/gochat/internal/channel/whatsapp"
"github.com/gochat/gochat/internal/model"
)
// WhatsAppWebhookHandler is a Gin adapter that wraps the WhatsApp
@@ -17,45 +22,59 @@ import (
//
// Chatwoot-style routes:
//
// GET /webhooks/whatsapp/:phone_number → HandleWhatsAppVerification
// POST /webhooks/whatsapp/:phone_number → HandleWhatsAppWebhook
// GET /webhooks/whatsapp/:phone_number → HandleWhatsAppVerification
// POST /webhooks/whatsapp/:phone_number → HandleWhatsAppWebhook
//
// WhatsApp Cloud API webhook verification (GET):
//
// The Meta platform sends a GET request with query parameters:
// - hub.mode = "subscribe"
// - hub.verify_token = the token configured in the Meta dashboard
// - hub.challenge = a string the endpoint must echo back verbatim
// The Meta platform sends a GET request with query parameters:
// - hub.mode = "subscribe"
// - hub.verify_token = the token configured in the Meta dashboard
// - hub.challenge = a string the endpoint must echo back verbatim
//
// The handler validates hub.verify_token against the stored token and
// returns hub.challenge as the response body with HTTP 200, or returns
// HTTP 403 on mismatch.
// The handler validates hub.verify_token against the stored token and
// returns hub.challenge as the response body with HTTP 200, or returns
// HTTP 403 on mismatch.
//
// WhatsApp Cloud API webhook events (POST):
//
// Meta delivers event payloads as JSON with structure:
// {
// "object": "whatsapp_business_account",
// "entry": [ { "changes": [ ... ] } ]
// }
// Meta delivers event payloads as JSON with structure:
// {
// "object": "whatsapp_business_account",
// "entry": [ { "changes": [ ... ] } ]
// }
//
// The handler parses the payload, dispatches incoming messages and status
// updates, and must respond with HTTP 200 OK within 10 seconds to avoid
// Meta retrying delivery.
// The handler parses the payload, dispatches incoming messages and status
// updates, and must respond with HTTP 200 OK within 10 seconds to avoid
// Meta retrying delivery.
type WhatsAppWebhookHandler struct {
provider *whatsapp.WhatsAppProvider
waWebhook *whatsapp.WebhookHandler
provider *whatsapp.WhatsAppProvider
waWebhook *whatsapp.WebhookHandler
persister *IncomingPersister
}
// NewWhatsAppWebhookHandler creates a Gin adapter wrapping the WhatsApp
// sub-package's WebhookHandler. The provider is stored for future use (e.g.
// health checks or direct API calls) while waWebhook is the core handler that
// processes verification and event requests.
func NewWhatsAppWebhookHandler(provider *whatsapp.WhatsAppProvider, waWebhook *whatsapp.WebhookHandler) *WhatsAppWebhookHandler {
return &WhatsAppWebhookHandler{
func NewWhatsAppWebhookHandler(provider *whatsapp.WhatsAppProvider, waWebhook *whatsapp.WebhookHandler, db *gorm.DB) *WhatsAppWebhookHandler {
h := &WhatsAppWebhookHandler{
provider: provider,
waWebhook: waWebhook,
persister: NewIncomingPersister(db),
}
if waWebhook != nil && h.persister != nil {
waWebhook.SetIncomingPersister(whatsAppPersisterAdapter{persister: h.persister})
}
return h
}
type whatsAppPersisterAdapter struct {
persister *IncomingPersister
}
func (a whatsAppPersisterAdapter) PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (interface{}, error) {
return a.persister.PersistIncoming(ctx, inbox, msg)
}
// HandleWhatsAppVerification handles GET requests for WhatsApp Cloud API
@@ -80,4 +99,4 @@ func (h *WhatsAppWebhookHandler) HandleWhatsAppVerification(c *gin.Context) {
// Expected Gin route: POST /webhooks/whatsapp/:phone_number
func (h *WhatsAppWebhookHandler) HandleWhatsAppWebhook(c *gin.Context) {
h.waWebhook.HandleWebhookEvent(c)
}
}