feat(webhook): persist provider receipt statuses

This commit is contained in:
2026-06-05 00:03:15 +08:00
parent 5eb726b124
commit 66ecabb984
8 changed files with 305 additions and 4 deletions
+41 -1
View File
@@ -25,11 +25,13 @@ package webhook
// - Same hub.mode/hub.verify_token/hub.challenge verification flow
import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
@@ -193,6 +195,7 @@ func (h *FacebookWebhookHandler) HandleFacebookWebhook(c *gin.Context) {
"event_type", event.EventType,
"inbox_id", inbox.ID,
)
h.persistFacebookReceipt(c.Request.Context(), inbox, event)
continue
}
@@ -423,7 +426,11 @@ func (h *FacebookWebhookHandler) instagramAppSecrets(events []*fbchannel.ParsedW
}
func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *model.Inbox, event *fbchannel.ParsedWebhookEvent) {
if fbchannel.IsEchoMessage(event) || fbchannel.IsDeliveryOrReadReceipt(event) || fbchannel.IsThreadControlEvent(event) {
if fbchannel.IsDeliveryOrReadReceipt(event) {
h.persistFacebookReceipt(c.Request.Context(), inbox, event)
return
}
if fbchannel.IsEchoMessage(event) || fbchannel.IsThreadControlEvent(event) {
return
}
@@ -463,6 +470,39 @@ func (h *FacebookWebhookHandler) processInstagramEvent(c *gin.Context, inbox *mo
}
}
func (h *FacebookWebhookHandler) persistFacebookReceipt(ctx context.Context, inbox *model.Inbox, event *fbchannel.ParsedWebhookEvent) {
if h.persister == nil {
return
}
switch event.EventType {
case fbchannel.EventFBDelivery, fbchannel.EventIGDelivery:
if event.Delivery == nil {
return
}
occurredAt := millisToTime(event.Delivery.Watermark)
for _, mid := range event.Delivery.Mids {
if err := h.persister.UpdateMessageStatus(ctx, inbox, mid, model.MessageStatusDelivered, occurredAt); err != nil {
applogger.L().Errorf("Facebook webhook: delivery status persistence failed mid=%s inbox=%d: %v", mid, inbox.ID, err)
}
}
case fbchannel.EventFBRead, fbchannel.EventIGRead:
if event.Read == nil {
return
}
if err := h.persister.UpdateContactConversationMessagesStatus(ctx, inbox, event.SenderID, model.MessageStatusRead, millisToTime(event.Read.Watermark)); err != nil {
applogger.L().Errorf("Facebook webhook: read status persistence failed sender=%s inbox=%d: %v", event.SenderID, inbox.ID, err)
}
}
}
func millisToTime(value int64) *time.Time {
if value <= 0 {
return nil
}
t := time.UnixMilli(value)
return &t
}
func parseOptionalUintParam(value string) (uint, error) {
if value == "" {
return 0, nil
@@ -90,6 +90,80 @@ func (p *IncomingPersister) PersistIncoming(ctx context.Context, inbox *model.In
return &result, nil
}
// UpdateMessageStatus applies provider delivery/read/failed receipts to existing messages.
func (p *IncomingPersister) UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error {
if p == nil || p.db == nil || inbox == nil || sourceID == "" {
return nil
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var message model.Message
if err := tx.Where("inbox_id = ? AND source_id = ?", inbox.ID, sourceID).First(&message).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return err
}
if err := tx.Model(&message).Update("status", string(status)).Error; err != nil {
return err
}
if message.SenderID == nil {
return nil
}
return p.upsertDeliveryStatus(ctx, tx, &message, *message.SenderID, status, occurredAt)
})
}
// UpdateContactConversationMessagesStatus applies read receipts that only identify the contact/conversation.
func (p *IncomingPersister) UpdateContactConversationMessagesStatus(ctx context.Context, inbox *model.Inbox, contactSourceID string, status model.MessageStatus, occurredAt *time.Time) error {
if p == nil || p.db == nil || inbox == nil || contactSourceID == "" {
return nil
}
return p.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var contactInbox model.ContactInbox
if err := tx.Where("inbox_id = ? AND source_id = ?", inbox.ID, contactSourceID).First(&contactInbox).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return err
}
query := tx.Model(&model.Message{}).
Joins("JOIN conversations ON conversations.id = messages.conversation_id").
Where("messages.inbox_id = ? AND conversations.contact_id = ?", inbox.ID, contactInbox.ContactID).
Where("messages.message_type = ?", model.MessageTypeOutgoing)
if occurredAt != nil {
query = query.Where("messages.created_at <= ?", *occurredAt)
}
return query.Update("status", string(status)).Error
})
}
func (p *IncomingPersister) upsertDeliveryStatus(ctx context.Context, tx *gorm.DB, message *model.Message, contactID uint, status model.MessageStatus, occurredAt *time.Time) error {
var delivery model.DeliveryStatus
err := tx.WithContext(ctx).Where("message_id = ? AND contact_id = ?", message.ID, contactID).First(&delivery).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
delivery = model.DeliveryStatus{MessageID: message.ID, InboxID: message.InboxID, ContactID: contactID}
}
delivery.Status = status
if occurredAt != nil {
switch status {
case model.MessageStatusRead:
delivery.ReadAt = occurredAt
case model.MessageStatusDelivered:
delivery.DeliveredAt = occurredAt
}
}
if delivery.ID == 0 {
return tx.WithContext(ctx).Create(&delivery).Error
}
return tx.WithContext(ctx).Save(&delivery).Error
}
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 {
@@ -86,6 +86,12 @@ func (h *TikTokWebhookHandler) HandleTikTokWebhook(c *gin.Context) {
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)
}
} else if event.Type == "message.read" {
if messageID := tiktokDataString(event.Data, "message_id"); messageID != "" {
if err := h.persister.UpdateMessageStatus(c.Request.Context(), inbox, messageID, model.MessageStatusRead, nil); err != nil {
applogger.L().Errorf("TikTok webhook: read status persistence failed for inbox %d source_id=%s: %v", inbox.ID, messageID, err)
}
}
}
c.JSON(http.StatusOK, gin.H{"status": "processed"})
@@ -169,6 +175,13 @@ func extractTikTokBusinessID(body []byte) string {
return ""
}
func tiktokDataString(data map[string]interface{}, key string) string {
if value, ok := data[key].(string); ok {
return value
}
return ""
}
// parseChannelConfig parses the JSON-encoded ChannelConfig string into a map.
func (h *TikTokWebhookHandler) parseChannelConfig(inbox *model.Inbox) map[string]interface{} {
if inbox.ChannelConfig == "" {
+28 -2
View File
@@ -87,8 +87,34 @@ func (h *TwilioWebhookHandler) HandleTwilioDeliveryStatus(c *gin.Context) {
return
}
// Dispatch to the channel-level delivery status handler
h.twilioWebhook.HandleDeliveryStatus(c.Writer, c.Request, inbox)
if err := c.Request.ParseForm(); err != nil {
applogger.L().Errorf("Twilio status webhook: parse form failed for inbox %d: %v", inbox.ID, err)
c.Status(http.StatusOK)
return
}
messageSID := c.Request.FormValue("MessageSid")
messageStatus := c.Request.FormValue("MessageStatus")
if mapped, ok := mapTwilioMessageStatus(messageStatus); ok {
if err := h.persister.UpdateMessageStatus(c.Request.Context(), inbox, messageSID, mapped, nil); err != nil {
applogger.L().Errorf("Twilio status webhook: status persistence failed for inbox %d sid=%s status=%s: %v", inbox.ID, messageSID, messageStatus, err)
}
}
c.Status(http.StatusOK)
}
func mapTwilioMessageStatus(status string) (model.MessageStatus, bool) {
switch status {
case "sent", "queued", "accepted", "sending":
return model.MessageStatusSent, true
case "delivered":
return model.MessageStatusDelivered, true
case "read":
return model.MessageStatusRead, true
case "undelivered", "failed":
return model.MessageStatusFailed, true
default:
return "", false
}
}
// lookupInbox fetches an Inbox record from the database.
@@ -35,6 +35,7 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
&model.ContactInbox{},
&model.Conversation{},
&model.Message{},
&model.DeliveryStatus{},
&channelmodel.ChannelTelegram{},
&channelmodel.ChannelLINE{},
&channelmodel.ChannelTwilioSMS{},
@@ -47,6 +48,46 @@ func newWebhookLookupTestDB(t *testing.T) *gorm.DB {
return db
}
func TestIncomingPersisterUpdatesMessageStatus(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "telegram")
persister := NewIncomingPersister(db)
msg := &channel.IncomingMessage{
ChannelType: channel.ChannelTelegram,
SourceID: "tg-status-1",
SenderID: "tg-user-status",
SenderName: "Status User",
SenderType: channel.SenderContact,
Content: "status me",
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 err := persister.UpdateMessageStatus(t.Context(), &inbox, "tg-status-1", model.MessageStatusRead, nil); err != nil {
t.Fatalf("update status: %v", err)
}
var message model.Message
if err := db.First(&message, result.Message.ID).Error; err != nil {
t.Fatalf("load message: %v", err)
}
if message.Status != string(model.MessageStatusRead) {
t.Fatalf("expected read status, got %s", message.Status)
}
var delivery model.DeliveryStatus
if err := db.Where("message_id = ? AND contact_id = ?", message.ID, result.Contact.ID).First(&delivery).Error; err != nil {
t.Fatalf("expected delivery status: %v", err)
}
if delivery.Status != model.MessageStatusRead {
t.Fatalf("expected delivery read, got %s", delivery.Status)
}
}
func TestIncomingPersisterCreatesConversationMessageAndDedupes(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "telegram")
@@ -236,6 +277,52 @@ func TestTwilioWebhookLookupInboxByPhoneNumber(t *testing.T) {
}
}
func TestTwilioDeliveryStatusUpdatesExistingMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "twilio_sms")
channelRecord := channelmodel.ChannelTwilioSMS{AccountID: 1, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
if err := db.Create(&channelRecord).Error; err != nil {
t.Fatalf("create twilio channel: %v", err)
}
contact := model.Contact{AccountID: inbox.AccountID, Name: "SMS Contact", Identifier: "+15550001111"}
if err := db.Create(&contact).Error; err != nil {
t.Fatalf("create contact: %v", err)
}
contactInbox := model.ContactInbox{ContactID: contact.ID, InboxID: inbox.ID, SourceID: "+15550001111", PubsubToken: "pub-twilio"}
if err := db.Create(&contactInbox).Error; err != nil {
t.Fatalf("create contact inbox: %v", err)
}
conversation := model.Conversation{AccountID: inbox.AccountID, InboxID: inbox.ID, ContactID: contact.ID, ContactInboxID: &contactInbox.ID, Status: string(model.ConversationStatusOpen), ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
if err := db.Create(&conversation).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
message := model.Message{ConversationID: conversation.ID, AccountID: inbox.AccountID, InboxID: inbox.ID, SenderID: &contact.ID, SenderType: string(model.SenderTypeContact), Content: "out", ContentType: string(model.MessageContentTypeText), MessageType: string(model.MessageTypeOutgoing), Status: string(model.MessageStatusSent), SourceID: "SM123"}
if err := db.Create(&message).Error; err != nil {
t.Fatalf("create message: %v", err)
}
h := NewTwilioWebhookHandler(nil, db)
r := gin.New()
r.POST("/webhooks/twilio/status/:phone_number", h.HandleTwilioDeliveryStatus)
req := httptest.NewRequest(http.MethodPost, "/webhooks/twilio/status/+15551234567", strings.NewReader("MessageSid=SM123&MessageStatus=delivered"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var updated model.Message
if err := db.First(&updated, message.ID).Error; err != nil {
t.Fatalf("load message: %v", err)
}
if updated.Status != string(model.MessageStatusDelivered) {
t.Fatalf("expected delivered, got %s", updated.Status)
}
}
func TestTikTokWebhookLookupInboxByBusinessIDAndPayloadExtractor(t *testing.T) {
db := newWebhookLookupTestDB(t)
inbox := seedWebhookInbox(t, db, "tiktok")
@@ -6,6 +6,7 @@ package webhook
import (
"context"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -77,6 +78,10 @@ func (a whatsAppPersisterAdapter) PersistIncoming(ctx context.Context, inbox *mo
return a.persister.PersistIncoming(ctx, inbox, msg)
}
func (a whatsAppPersisterAdapter) UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error {
return a.persister.UpdateMessageStatus(ctx, inbox, sourceID, status, occurredAt)
}
// HandleWhatsAppVerification handles GET requests for WhatsApp Cloud API
// webhook verification. Meta sends this request during initial webhook setup
// and periodic re-verification. It delegates to the underlying