fix(messages): normalize webhook reply references
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user