fix(messages): normalize webhook reply references

This commit is contained in:
2026-07-12 22:17:04 +08:00
parent 32d3329d1b
commit 813faa4cd7
4 changed files with 182 additions and 27 deletions
@@ -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")