From 66ecabb984cb31e3c0f1645b198624a75c8de983 Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 5 Jun 2026 00:01:43 +0800 Subject: [PATCH] feat(webhook): persist provider receipt statuses --- docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md | 5 +- internal/channel/whatsapp/webhook_handler.go | 53 +++++++++++ internal/handler/webhook/facebook_webhook.go | 42 ++++++++- .../handler/webhook/incoming_persister.go | 74 ++++++++++++++++ internal/handler/webhook/tiktok_webhook.go | 13 +++ internal/handler/webhook/twilio_webhook.go | 30 ++++++- .../handler/webhook/webhook_lookup_test.go | 87 +++++++++++++++++++ internal/handler/webhook/whatsapp_webhook.go | 5 ++ 8 files changed, 305 insertions(+), 4 deletions(-) diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index 5e8be769..c75d4c5b 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -60,7 +60,8 @@ This ledger records the committed parity checkpoints that future slices should b | `6aa62c6 docs: consolidate chatwoot parity roadmap` | Promoted Hermes-era plans into this master tracker; locked user decisions; added milestone, slice, enterprise, and webhook provider tracking. | Documentation-only checkpoint. | B1/P6.7 selected as next implementation slice. | | `9e3f561 feat(webhook): align chatwoot ingress routes` | Added Chatwoot public webhook paths for Twitter, Telegram, LINE, SMS/Twilio, WhatsApp, Instagram, TikTok, and Shopify; removed generic fallback success masking. | `go test ./...`; route dump regenerated with `TOTAL: 801`; route parity stayed `251 exact, 0 missing`. | Provider-specific lookup and verification moved to review. | | `bc7da9e feat(webhook): implement instagram and shopify ingress` | Implemented Instagram verify/event handling, Shopify HMAC/redact/event forwarding, and WhatsApp verify-token/app-secret signature corrections. | Focused webhook tests, `go test ./...`, route dump `TOTAL: 801`, route parity `251 exact, 0 missing`, `git diff --check`. | Remaining P6.7 work is durable incoming-message persistence and provider dispatch parity. | -| Working tree | Added provider incoming-message persistence boundary and wired Telegram, LINE, SMS/Twilio, WhatsApp, Facebook/Instagram, and TikTok parsed incoming messages into ContactInbox, Conversation, and Message storage. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with delivery/read receipt status updates and async dispatch/events. | +| `5eb726b feat(webhook): persist incoming provider messages` | Added provider incoming-message persistence boundary and wired Telegram, LINE, SMS/Twilio, WhatsApp, Facebook/Instagram, and TikTok parsed incoming messages into ContactInbox, Conversation, and Message storage. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with delivery/read receipt status updates and async dispatch/events. | +| Current receipt status slice | Added delivery/read/failed status persistence for Twilio, WhatsApp, Facebook/Instagram, and TikTok receipt events. | Focused webhook/channel tests passed; full `go test ./...` passed. | Continue P6.7 review with async dispatch/events and broader provider fixture assertions. | ## Next Slice Contract @@ -79,6 +80,7 @@ Current N1/N2 implementation checkpoint: - Added `IncomingPersister` as the shared durable webhook boundary. It dedupes messages by `inbox_id + source_id`, resolves or creates `ContactInbox` by `inbox_id + sender source_id`, reuses the latest open conversation for the contact/inbox, and creates incoming `Message` records with content attributes and provider metadata. - Wired parsed incoming messages from Telegram, LINE, Twilio SMS, WhatsApp, Facebook/Instagram, and TikTok into the persister. Provider verification and provider-safe acknowledgement behavior remain in their existing handlers. - Added regression coverage for direct persistence, duplicate suppression, and Telegram webhook-to-message persistence. Broader provider-specific persistence assertions remain required before P6.7 is `Done`. +- Added receipt persistence for status-only webhook events. Twilio delivery callbacks, WhatsApp statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts now update existing message statuses through the same boundary. Async event fan-out remains the next dispatch gap. ## Immediate Execution Queue @@ -597,3 +599,4 @@ Verification milestone gates: - 2026-06-04: Started P6.7 webhook ingress parity. Added Chatwoot public webhook paths for Twitter, Telegram, LINE, SMS/Twilio, WhatsApp, Instagram, TikTok, and Shopify; removed the generic success fallback so unsupported providers no longer return placeholder success. Telegram, LINE, Twilio SMS, and TikTok handlers now resolve inboxes through provider channel records instead of inbox-id placeholders; TikTok model column naming now matches existing repository queries. Added provider lookup tests and router boot coverage. Regenerated route dump: `TOTAL: 801`; tracked route parity remains `251 exact, 0 missing`. - 2026-06-04: Continued P6.7 webhook ingress parity. Instagram `/webhooks/instagram` now performs Chatwoot-style global verify-token challenge handling, verifies Meta signatures against env/channel app secrets, resolves Instagram inboxes from webhook sender/recipient IDs, and dispatches parsed DM/comment events through the existing Meta pipeline boundary. Shopify `/webhooks/shopify` now verifies `X-Shopify-Hmac-SHA256` with `SHOPIFY_CLIENT_SECRET`, handles `shop/redact` by deleting matching Shopify integration hooks, and forwards supported topics to the existing Shopify event processor. WhatsApp verification now queries channels by verify token directly, and Cloud API signature verification uses app secrets from provider config/env instead of access tokens. Added focused webhook tests; route dump remains `TOTAL: 801` and tracked route parity remains `251 exact, 0 missing`. - 2026-06-04: Added the P6.7 incoming persistence boundary. Provider webhook handlers now persist normalized incoming messages into `contacts`, `contact_inboxes`, open `conversations`, and incoming `messages` instead of only parsing/logging them. Telegram, LINE, Twilio SMS, WhatsApp, Facebook/Instagram, and TikTok are wired through the shared persister; duplicates are skipped by `inbox_id + source_id`. Added direct persister coverage and Telegram webhook persistence coverage. Focused webhook/channel tests and full `go test ./...` passed. +- 2026-06-04: Continued P6.7 dispatch parity by persisting provider receipt/status events. Existing messages are updated from Twilio delivery callbacks, WhatsApp sent/delivered/read/failed statuses, Facebook/Instagram delivery/read receipts, and TikTok read receipts. Added tests for direct status update and Twilio delivery callback update. Focused webhook/channel tests and full `go test ./...` passed. diff --git a/internal/channel/whatsapp/webhook_handler.go b/internal/channel/whatsapp/webhook_handler.go index d934ee1b..083a2678 100644 --- a/internal/channel/whatsapp/webhook_handler.go +++ b/internal/channel/whatsapp/webhook_handler.go @@ -24,6 +24,8 @@ import ( "io" "net/http" "os" + "strconv" + "time" "github.com/gin-gonic/gin" @@ -41,6 +43,7 @@ type WebhookHandler struct { type IncomingPersister interface { PersistIncoming(ctx context.Context, inbox *model.Inbox, msg *channel.IncomingMessage) (interface{}, error) + UpdateMessageStatus(ctx context.Context, inbox *model.Inbox, sourceID string, status model.MessageStatus, occurredAt *time.Time) error } // NewWebhookHandler creates a WhatsApp webhook handler. @@ -155,11 +158,61 @@ func (h *WebhookHandler) HandleWebhookEvent(c *gin.Context) { } } } + if h.persister != nil { + h.persistStatusUpdates(c.Request.Context(), inbox, event) + } // Always return 200 OK — WhatsApp requires fast response c.JSON(http.StatusOK, gin.H{"status": "received"}) } +func (h *WebhookHandler) persistStatusUpdates(ctx context.Context, inbox *model.Inbox, event *WAWebhookEvent) { + for _, entry := range event.Entry { + for _, change := range entry.Changes { + for _, status := range change.Value.Statuses { + mapped, ok := mapWhatsAppMessageStatus(status.Status) + if !ok { + continue + } + var occurredAt *time.Time + if parsed := parseUnixTimestamp(status.Timestamp); parsed != nil { + occurredAt = parsed + } + if err := h.persister.UpdateMessageStatus(ctx, inbox, status.ID, mapped, occurredAt); err != nil { + applogger.L().Error("WhatsApp webhook: status persistence failed", "message_id", status.ID, "status", status.Status, "error", err) + } + } + } + } +} + +func mapWhatsAppMessageStatus(status string) (model.MessageStatus, bool) { + switch status { + case "sent": + return model.MessageStatusSent, true + case "delivered": + return model.MessageStatusDelivered, true + case "read": + return model.MessageStatusRead, true + case "failed": + return model.MessageStatusFailed, true + default: + return "", false + } +} + +func parseUnixTimestamp(value string) *time.Time { + if value == "" { + return nil + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed <= 0 { + return nil + } + t := time.Unix(parsed, 0) + return &t +} + // HandleWebhookVerification is an alias for HandleVerification for routing convenience. func (h *WebhookHandler) HandleWebhookVerification(c *gin.Context) { h.HandleVerification(c) diff --git a/internal/handler/webhook/facebook_webhook.go b/internal/handler/webhook/facebook_webhook.go index bbf231b3..ea996b4a 100644 --- a/internal/handler/webhook/facebook_webhook.go +++ b/internal/handler/webhook/facebook_webhook.go @@ -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 diff --git a/internal/handler/webhook/incoming_persister.go b/internal/handler/webhook/incoming_persister.go index d8c253c2..b97a5c75 100644 --- a/internal/handler/webhook/incoming_persister.go +++ b/internal/handler/webhook/incoming_persister.go @@ -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 { diff --git a/internal/handler/webhook/tiktok_webhook.go b/internal/handler/webhook/tiktok_webhook.go index b88d1a66..a98bcaa8 100644 --- a/internal/handler/webhook/tiktok_webhook.go +++ b/internal/handler/webhook/tiktok_webhook.go @@ -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 == "" { diff --git a/internal/handler/webhook/twilio_webhook.go b/internal/handler/webhook/twilio_webhook.go index 215d1a9e..1ad6e52a 100644 --- a/internal/handler/webhook/twilio_webhook.go +++ b/internal/handler/webhook/twilio_webhook.go @@ -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. diff --git a/internal/handler/webhook/webhook_lookup_test.go b/internal/handler/webhook/webhook_lookup_test.go index 542646cf..6014069d 100644 --- a/internal/handler/webhook/webhook_lookup_test.go +++ b/internal/handler/webhook/webhook_lookup_test.go @@ -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") diff --git a/internal/handler/webhook/whatsapp_webhook.go b/internal/handler/webhook/whatsapp_webhook.go index c5173adc..4a503b7f 100644 --- a/internal/handler/webhook/whatsapp_webhook.go +++ b/internal/handler/webhook/whatsapp_webhook.go @@ -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