feat(webhook): persist provider receipt statuses
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user