diff --git a/AGENTS.md b/AGENTS.md index 4b7903b2..7e478246 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`: diff --git a/backend/internal/handler/webhook/incoming_persister.go b/backend/internal/handler/webhook/incoming_persister.go index fb2c82e6..04419fdf 100644 --- a/backend/internal/handler/webhook/incoming_persister.go +++ b/backend/internal/handler/webhook/incoming_persister.go @@ -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, diff --git a/backend/internal/handler/webhook/webhook_lookup_test.go b/backend/internal/handler/webhook/webhook_lookup_test.go index e5087265..c17ebc13 100644 --- a/backend/internal/handler/webhook/webhook_lookup_test.go +++ b/backend/internal/handler/webhook/webhook_lookup_test.go @@ -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") diff --git a/frontend/app/javascript/dashboard/components-next/message/MessageList.vue b/frontend/app/javascript/dashboard/components-next/message/MessageList.vue index df689a71..4212dc33 100644 --- a/frontend/app/javascript/dashboard/components-next/message/MessageList.vue +++ b/frontend/app/javascript/dashboard/components-next/message/MessageList.vue @@ -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} - 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