fix(messages): normalize webhook reply references
This commit is contained in:
@@ -3,7 +3,9 @@ package webhook
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -538,8 +540,27 @@ func (p *IncomingPersister) createMessage(ctx context.Context, tx *gorm.DB, inbo
|
||||
if len(msg.Attachments) > 0 {
|
||||
contentAttrs["attachments"] = msg.Attachments
|
||||
}
|
||||
if msg.ReplyToID != "" {
|
||||
contentAttrs["in_reply_to"] = msg.ReplyToID
|
||||
if replyToExternalID := strings.TrimSpace(msg.ReplyToID); replyToExternalID != "" {
|
||||
// Chatwoot stores provider reply references as in_reply_to_external_id,
|
||||
// then resolves the same-conversation message by source_id before save.
|
||||
// Keep in_reply_to strictly as the internal numeric message ID expected by
|
||||
// the dashboard and its cursor-based message API.
|
||||
var replyMessage model.Message
|
||||
err := tx.WithContext(ctx).
|
||||
Where("conversation_id = ? AND source_id = ?", conversation.ID, replyToExternalID).
|
||||
First(&replyMessage).Error
|
||||
if err == nil {
|
||||
contentAttrs["in_reply_to"] = replyMessage.ID
|
||||
contentAttrs["in_reply_to_external_id"] = replyMessage.SourceID
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// Match Chatwoot's InReplyToMessageBuilder: unresolved references
|
||||
// are normalized to null instead of being persisted as an invalid
|
||||
// internal message ID.
|
||||
contentAttrs["in_reply_to"] = nil
|
||||
contentAttrs["in_reply_to_external_id"] = nil
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
message := model.Message{
|
||||
ConversationID: conversation.ID,
|
||||
|
||||
@@ -575,6 +575,81 @@ func TestIncomingPersisterCreatesConversationMessageAndDedupes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingPersisterNormalizesExternalReplyReference(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "telegram")
|
||||
persister := NewIncomingPersister(db)
|
||||
|
||||
original, err := persister.PersistIncoming(t.Context(), &inbox, &channel.IncomingMessage{
|
||||
ChannelType: channel.ChannelTelegram,
|
||||
SourceID: "tg-reply-source-1",
|
||||
SenderID: "tg-reply-user-1",
|
||||
SenderName: "Reply User",
|
||||
SenderType: channel.SenderContact,
|
||||
Content: "original",
|
||||
ContentType: channel.ContentText,
|
||||
InboxID: inbox.ID,
|
||||
AccountID: inbox.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("persist original message: %v", err)
|
||||
}
|
||||
|
||||
reply, err := persister.PersistIncoming(t.Context(), &inbox, &channel.IncomingMessage{
|
||||
ChannelType: channel.ChannelTelegram,
|
||||
SourceID: "tg-reply-source-2",
|
||||
SenderID: "tg-reply-user-1",
|
||||
SenderName: "Reply User",
|
||||
SenderType: channel.SenderContact,
|
||||
Content: "reply",
|
||||
ContentType: channel.ContentText,
|
||||
ReplyToID: "tg-reply-source-1",
|
||||
InboxID: inbox.ID,
|
||||
AccountID: inbox.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("persist reply message: %v", err)
|
||||
}
|
||||
|
||||
attrs := map[string]interface{}{}
|
||||
if err := json.Unmarshal(reply.Message.ContentAttributes, &attrs); err != nil {
|
||||
t.Fatalf("decode reply content attributes: %v", err)
|
||||
}
|
||||
if got := uint(attrs["in_reply_to"].(float64)); got != original.Message.ID {
|
||||
t.Fatalf("expected internal in_reply_to %d, got %#v", original.Message.ID, attrs["in_reply_to"])
|
||||
}
|
||||
if got := attrs["in_reply_to_external_id"]; got != original.Message.SourceID {
|
||||
t.Fatalf("expected external reply id %q, got %#v", original.Message.SourceID, got)
|
||||
}
|
||||
|
||||
unresolved, err := persister.PersistIncoming(t.Context(), &inbox, &channel.IncomingMessage{
|
||||
ChannelType: channel.ChannelTelegram,
|
||||
SourceID: "tg-reply-source-3",
|
||||
SenderID: "tg-reply-user-1",
|
||||
SenderName: "Reply User",
|
||||
SenderType: channel.SenderContact,
|
||||
Content: "unresolved reply",
|
||||
ContentType: channel.ContentText,
|
||||
ReplyToID: "tg-missing-source",
|
||||
InboxID: inbox.ID,
|
||||
AccountID: inbox.AccountID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("persist unresolved reply message: %v", err)
|
||||
}
|
||||
|
||||
unresolvedAttrs := map[string]interface{}{}
|
||||
if err := json.Unmarshal(unresolved.Message.ContentAttributes, &unresolvedAttrs); err != nil {
|
||||
t.Fatalf("decode unresolved reply content attributes: %v", err)
|
||||
}
|
||||
if got, ok := unresolvedAttrs["in_reply_to"]; !ok || got != nil {
|
||||
t.Fatalf("expected unresolved internal reply id to be null, got %#v", unresolvedAttrs)
|
||||
}
|
||||
if got, ok := unresolvedAttrs["in_reply_to_external_id"]; !ok || got != nil {
|
||||
t.Fatalf("expected unresolved external reply id to be null, got %#v", unresolvedAttrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncomingPersisterQueuesIncomingMessageWithWorker(t *testing.T) {
|
||||
db := newWebhookLookupTestDB(t)
|
||||
inbox := seedWebhookInbox(t, db, "telegram")
|
||||
|
||||
Reference in New Issue
Block a user