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
+20
View File
@@ -28,11 +28,31 @@ deploy/ # Deployment artifacts
quickstart/ # One-shot local/UAT Compose stack (PG+pgvector+Redis+Meilisearch+Mailhog)
fluentd/ # Log shipping config
docs/ # Project documentation (architecture, requirements, plans, reports)
chatwoot/ # Upstream Chatwoot source snapshot; primary behavioral reference
```
The **Go module root is `backend/`** — run all `go` commands from there.
Build context for Docker is the **repository root** (`.`), not `backend/`.
## Upstream Chatwoot Reference (Required)
GoChat is a Go-language translation and port of Chatwoot. When a behavior,
API contract, data model, event payload, frontend interaction, or edge case is
unclear or appears incorrect, **inspect the upstream implementation under
`docs/chatwoot/` before designing or changing the GoChat solution**.
- Trace the complete upstream path first: controller/handler → service/builder
→ model callback → serializer → frontend consumer.
- Preserve Chatwoot's externally visible behavior and payload contracts when
translating Rails/Ruby implementation details into GoChat's layered Go
architecture.
- Reuse the upstream fix pattern and regression cases where practical instead
of inventing a parallel behavior from the current Go code alone.
- Treat `docs/chatwoot/` as read-only reference code. Production changes belong
in `backend/`, `frontend/`, or other GoChat-owned directories.
- If GoChat intentionally diverges from Chatwoot, document the reason, affected
contract, and verification coverage in the change.
## Build & Test Commands
All Go commands run from `backend/`:
@@ -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")
@@ -52,6 +52,16 @@ const currentChat = useMapGetter('getSelectedChat');
// Cache for fetched reply messages to avoid duplicate API calls
const fetchedReplyMessages = reactive(new Map());
const pendingReplyMessageFetches = new Map();
const replyMessageMatches = (message, referenceId) => {
const normalizedReferenceId = String(referenceId);
return (
String(message.id) === normalizedReferenceId ||
message.source_id === normalizedReferenceId ||
message.sourceId === normalizedReferenceId
);
};
/**
* Fetches a specific message from the API by trying to get messages around it
@@ -60,33 +70,58 @@ const fetchedReplyMessages = reactive(new Map());
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
*/
const fetchReplyMessage = async (messageId, conversationId) => {
const cacheKey = String(messageId);
// Return cached result if already fetched
if (fetchedReplyMessages.has(messageId)) {
return fetchedReplyMessages.get(messageId);
if (fetchedReplyMessages.has(cacheKey)) {
return fetchedReplyMessages.get(cacheKey);
}
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: messageId + 100,
after: messageId - 100,
});
const numericMessageId = Number(messageId);
if (!Number.isSafeInteger(numericMessageId) || numericMessageId <= 0) {
// Legacy provider payloads may contain an external source ID here. Never
// pass those strings to the numeric before/after cursor endpoint.
fetchedReplyMessages.set(cacheKey, null);
return null;
}
const messages = response.data?.payload || [];
const targetMessage = messages.find(msg => msg.id === messageId);
if (pendingReplyMessageFetches.has(cacheKey)) {
return pendingReplyMessageFetches.get(cacheKey);
}
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(messageId, camelCaseMessage);
return camelCaseMessage;
const request = (async () => {
try {
const response = await MessageApi.getPreviousMessages({
conversationId,
before: numericMessageId + 100,
after: numericMessageId - 100,
});
const messages = response.data?.payload || [];
const targetMessage = messages.find(
message => Number(message.id) === numericMessageId
);
if (targetMessage) {
const camelCaseMessage = useCamelCase(targetMessage);
fetchedReplyMessages.set(cacheKey, camelCaseMessage);
return camelCaseMessage;
}
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(cacheKey, null);
return null;
} catch (error) {
fetchedReplyMessages.set(cacheKey, null);
return null;
}
})();
// Cache null result to avoid repeated API calls
fetchedReplyMessages.set(messageId, null);
return null;
} catch (error) {
fetchedReplyMessages.set(messageId, null);
return null;
pendingReplyMessageFetches.set(cacheKey, request);
try {
return await request;
} finally {
pendingReplyMessageFetches.delete(cacheKey);
}
};
@@ -137,19 +172,23 @@ const getInReplyToMessage = parentMessage => {
if (!inReplyToMessageId) return null;
const cacheKey = String(inReplyToMessageId);
// Try to find in current messages first
let replyMessage = props.messages?.find(msg => msg.id === inReplyToMessageId);
let replyMessage = props.messages?.find(message =>
replyMessageMatches(message, inReplyToMessageId)
);
// Then try store messages
if (!replyMessage && currentChat.value?.messages) {
replyMessage = currentChat.value.messages.find(
msg => msg.id === inReplyToMessageId
replyMessage = currentChat.value.messages.find(message =>
replyMessageMatches(message, inReplyToMessageId)
);
}
// Then check fetch cache
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
if (!replyMessage && fetchedReplyMessages.has(cacheKey)) {
replyMessage = fetchedReplyMessages.get(cacheKey);
}
// If still not found and we have conversation context, fetch it