feat(webhook): persist incoming provider messages
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user