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)
|
quickstart/ # One-shot local/UAT Compose stack (PG+pgvector+Redis+Meilisearch+Mailhog)
|
||||||
fluentd/ # Log shipping config
|
fluentd/ # Log shipping config
|
||||||
docs/ # Project documentation (architecture, requirements, plans, reports)
|
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.
|
The **Go module root is `backend/`** — run all `go` commands from there.
|
||||||
Build context for Docker is the **repository root** (`.`), not `backend/`.
|
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
|
## Build & Test Commands
|
||||||
|
|
||||||
All Go commands run from `backend/`:
|
All Go commands run from `backend/`:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package webhook
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -538,8 +540,27 @@ func (p *IncomingPersister) createMessage(ctx context.Context, tx *gorm.DB, inbo
|
|||||||
if len(msg.Attachments) > 0 {
|
if len(msg.Attachments) > 0 {
|
||||||
contentAttrs["attachments"] = msg.Attachments
|
contentAttrs["attachments"] = msg.Attachments
|
||||||
}
|
}
|
||||||
if msg.ReplyToID != "" {
|
if replyToExternalID := strings.TrimSpace(msg.ReplyToID); replyToExternalID != "" {
|
||||||
contentAttrs["in_reply_to"] = msg.ReplyToID
|
// 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{
|
message := model.Message{
|
||||||
ConversationID: conversation.ID,
|
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) {
|
func TestIncomingPersisterQueuesIncomingMessageWithWorker(t *testing.T) {
|
||||||
db := newWebhookLookupTestDB(t)
|
db := newWebhookLookupTestDB(t)
|
||||||
inbox := seedWebhookInbox(t, db, "telegram")
|
inbox := seedWebhookInbox(t, db, "telegram")
|
||||||
|
|||||||
@@ -52,6 +52,16 @@ const currentChat = useMapGetter('getSelectedChat');
|
|||||||
|
|
||||||
// Cache for fetched reply messages to avoid duplicate API calls
|
// Cache for fetched reply messages to avoid duplicate API calls
|
||||||
const fetchedReplyMessages = reactive(new Map());
|
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
|
* 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
|
* @returns {Promise<Object|null>} - The fetched message or null if not found/error
|
||||||
*/
|
*/
|
||||||
const fetchReplyMessage = async (messageId, conversationId) => {
|
const fetchReplyMessage = async (messageId, conversationId) => {
|
||||||
|
const cacheKey = String(messageId);
|
||||||
|
|
||||||
// Return cached result if already fetched
|
// Return cached result if already fetched
|
||||||
if (fetchedReplyMessages.has(messageId)) {
|
if (fetchedReplyMessages.has(cacheKey)) {
|
||||||
return fetchedReplyMessages.get(messageId);
|
return fetchedReplyMessages.get(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const numericMessageId = Number(messageId);
|
||||||
const response = await MessageApi.getPreviousMessages({
|
if (!Number.isSafeInteger(numericMessageId) || numericMessageId <= 0) {
|
||||||
conversationId,
|
// Legacy provider payloads may contain an external source ID here. Never
|
||||||
before: messageId + 100,
|
// pass those strings to the numeric before/after cursor endpoint.
|
||||||
after: messageId - 100,
|
fetchedReplyMessages.set(cacheKey, null);
|
||||||
});
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const messages = response.data?.payload || [];
|
if (pendingReplyMessageFetches.has(cacheKey)) {
|
||||||
const targetMessage = messages.find(msg => msg.id === messageId);
|
return pendingReplyMessageFetches.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (targetMessage) {
|
const request = (async () => {
|
||||||
const camelCaseMessage = useCamelCase(targetMessage);
|
try {
|
||||||
fetchedReplyMessages.set(messageId, camelCaseMessage);
|
const response = await MessageApi.getPreviousMessages({
|
||||||
return camelCaseMessage;
|
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
|
pendingReplyMessageFetches.set(cacheKey, request);
|
||||||
fetchedReplyMessages.set(messageId, null);
|
try {
|
||||||
return null;
|
return await request;
|
||||||
} catch (error) {
|
} finally {
|
||||||
fetchedReplyMessages.set(messageId, null);
|
pendingReplyMessageFetches.delete(cacheKey);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,19 +172,23 @@ const getInReplyToMessage = parentMessage => {
|
|||||||
|
|
||||||
if (!inReplyToMessageId) return null;
|
if (!inReplyToMessageId) return null;
|
||||||
|
|
||||||
|
const cacheKey = String(inReplyToMessageId);
|
||||||
|
|
||||||
// Try to find in current messages first
|
// 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
|
// Then try store messages
|
||||||
if (!replyMessage && currentChat.value?.messages) {
|
if (!replyMessage && currentChat.value?.messages) {
|
||||||
replyMessage = currentChat.value.messages.find(
|
replyMessage = currentChat.value.messages.find(message =>
|
||||||
msg => msg.id === inReplyToMessageId
|
replyMessageMatches(message, inReplyToMessageId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then check fetch cache
|
// Then check fetch cache
|
||||||
if (!replyMessage && fetchedReplyMessages.has(inReplyToMessageId)) {
|
if (!replyMessage && fetchedReplyMessages.has(cacheKey)) {
|
||||||
replyMessage = fetchedReplyMessages.get(inReplyToMessageId);
|
replyMessage = fetchedReplyMessages.get(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If still not found and we have conversation context, fetch it
|
// If still not found and we have conversation context, fetch it
|
||||||
|
|||||||
Reference in New Issue
Block a user