diff --git a/internal/channels/replycontext/reply-context-cache.go b/internal/channels/replycontext/reply-context-cache.go index 61d78921..772663da 100644 --- a/internal/channels/replycontext/reply-context-cache.go +++ b/internal/channels/replycontext/reply-context-cache.go @@ -23,11 +23,12 @@ type Scope struct { // Message is a platform-normalized accepted message stored for future replies. type Message struct { - IDs []string - ParentIDs []string - Sender string - Body string - CreatedAt time.Time + IDs []string + ParentIDs []string + ParentQuote Quote + Sender string + Body string + CreatedAt time.Time } // Quote is the platform-normalized immediate quote payload on a reply. @@ -61,12 +62,13 @@ type cacheKey struct { } type node struct { - key cacheKey - ids []string - parentIDs []string - sender string - body string - createdAt time.Time + key cacheKey + ids []string + parentIDs []string + parentQuote Quote + sender string + body string + createdAt time.Time } func DefaultOptions() Options { @@ -132,13 +134,16 @@ func (c *Cache) Store(scope Scope, msg Message) { if old := c.nodes[key]; old != nil { c.deleteNodeLocked(key, old) } + parentQuote := normalizeQuote(msg.ParentQuote, c.opts) + parentIDs := NormalizeIDs(append(msg.ParentIDs, parentQuote.IDs...)) n := &node{ - key: key, - ids: ids, - parentIDs: NormalizeIDs(msg.ParentIDs), - sender: strings.TrimSpace(msg.Sender), - body: body, - createdAt: createdAt, + key: key, + ids: ids, + parentIDs: parentIDs, + parentQuote: parentQuote, + sender: strings.TrimSpace(msg.Sender), + body: body, + createdAt: createdAt, } c.nodes[key] = n for _, id := range ids { diff --git a/internal/channels/replycontext/reply-context-cache_test.go b/internal/channels/replycontext/reply-context-cache_test.go index cc198ab2..bdd6bd14 100644 --- a/internal/channels/replycontext/reply-context-cache_test.go +++ b/internal/channels/replycontext/reply-context-cache_test.go @@ -29,6 +29,39 @@ func TestCacheBuild_MultiLevelReplyChain(t *testing.T) { } } +func TestCacheBuild_MultiLevelReplyChainWithUncachedRootQuote(t *testing.T) { + cache := NewCache(Options{MaxDepth: 8, MaxTotalChars: 6000, MaxCharsPerMessage: 1000}) + scope := Scope{TenantID: "tenant-a", ChannelID: "zalo-main", ThreadID: "thread-1"} + + cache.Store(scope, Message{ + IDs: []string{"reply-1"}, + ParentIDs: []string{"root"}, + ParentQuote: Quote{ + IDs: []string{"root"}, + Sender: "Agent", + Body: "original outbound message", + }, + Sender: "Alice", + Body: "reply 1", + }) + cache.Store(scope, Message{IDs: []string{"reply-2"}, ParentIDs: []string{"reply-1"}, Sender: "Bob", Body: "reply 2"}) + cache.Store(scope, Message{IDs: []string{"reply-3"}, ParentIDs: []string{"reply-2"}, Sender: "Carol", Body: "reply 3"}) + cache.Store(scope, Message{IDs: []string{"reply-4"}, ParentIDs: []string{"reply-3"}, Sender: "Dan", Body: "reply 4"}) + cache.Store(scope, Message{IDs: []string{"reply-5"}, ParentIDs: []string{"reply-4"}, Sender: "Eve", Body: "reply 5"}) + cache.Store(scope, Message{IDs: []string{"reply-6"}, ParentIDs: []string{"reply-5"}, Sender: "Frank", Body: "reply 6"}) + + got := cache.Build(scope, Quote{IDs: []string{"reply-6"}, Sender: "Frank", Body: "direct fallback"}) + for _, want := range []string{"original outbound message", "reply 1", "reply 2", "reply 3", "reply 4", "reply 5", "reply 6"} { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } + assertOrder(t, got, "original outbound message", "reply 1", "reply 2", "reply 3", "reply 4", "reply 5", "reply 6") + if strings.Contains(got, "direct fallback") { + t.Fatalf("used fallback despite cache hit: %q", got) + } +} + func TestCacheBuild_FallbackOnCacheMiss(t *testing.T) { cache := NewCache(Options{}) scope := Scope{TenantID: "tenant-a", ChannelID: "zalo-main", ThreadID: "thread-1"} diff --git a/internal/channels/replycontext/reply-context-render.go b/internal/channels/replycontext/reply-context-render.go index b33bc96e..f9494827 100644 --- a/internal/channels/replycontext/reply-context-render.go +++ b/internal/channels/replycontext/reply-context-render.go @@ -28,6 +28,14 @@ func RenderQuote(quote Quote) string { return renderQuote(quote, DefaultOptions()) } +func normalizeQuote(quote Quote, opts Options) Quote { + return Quote{ + IDs: NormalizeIDs(quote.IDs), + Sender: strings.TrimSpace(quote.Sender), + Body: truncateText(strings.TrimSpace(quote.Body), opts.MaxCharsPerMessage), + } +} + func renderQuote(quote Quote, opts Options) string { body := strings.TrimSpace(quote.Body) if body == "" { diff --git a/internal/channels/replycontext/reply-context-store.go b/internal/channels/replycontext/reply-context-store.go index d91b22b5..0c3833c1 100644 --- a/internal/channels/replycontext/reply-context-store.go +++ b/internal/channels/replycontext/reply-context-store.go @@ -36,7 +36,13 @@ func (c *Cache) collectChainLocked(scope Scope, start *node) []renderMessage { } visited[n.key] = struct{}{} reversed = append(reversed, renderMessage{sender: n.sender, body: n.body}) - n = c.lookupLocked(scope, n.parentIDs) + parent := c.lookupLocked(scope, n.parentIDs) + if parent == nil && len(reversed) < c.opts.MaxDepth { + if fallback := n.parentFallback(); fallback.body != "" { + reversed = append(reversed, fallback) + } + } + n = parent } for i, j := 0, len(reversed)-1; i < j; i, j = i+1, j-1 { @@ -45,6 +51,13 @@ func (c *Cache) collectChainLocked(scope Scope, start *node) []renderMessage { return reversed } +func (n *node) parentFallback() renderMessage { + return renderMessage{ + sender: n.parentQuote.Sender, + body: n.parentQuote.Body, + } +} + func (c *Cache) purgeExpiredLocked(now time.Time) { if c.opts.TTL <= 0 { return diff --git a/internal/channels/zalo/personal/handlers_test.go b/internal/channels/zalo/personal/handlers_test.go index d600667c..8344ebbe 100644 --- a/internal/channels/zalo/personal/handlers_test.go +++ b/internal/channels/zalo/personal/handlers_test.go @@ -123,6 +123,108 @@ func TestZaloReplyContext_BuildsMultiLevelChainFromCache(t *testing.T) { } } +func TestZaloReplyContext_KeepsUncachedOriginalQuoteAcrossDeepReplies(t *testing.T) { + ch := newTestChannel(t, config.ZaloPersonalConfig{DMPolicy: "open", GroupPolicy: "open"}) + threadID := "direct-1" + + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-1", + CliMsgID: "cli-1", + UIDFrom: "u-1", + Quote: &protocol.TQuote{ + OwnerID: "agent", + CliMsgID: "cli-root", + GlobalMsgID: "msg-root", + FromD: "Tue Linh Pm - Cppai", + Msg: "Chao ban! Minh la CPPAI PM.", + }, + }, "Anh Duc Day") + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-2", + CliMsgID: "cli-2", + UIDFrom: "u-2", + Quote: &protocol.TQuote{ + OwnerID: "u-1", + CliMsgID: "cli-1", + GlobalMsgID: "msg-1", + }, + }, "Em biet anh la ai khong?") + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-3", + CliMsgID: "cli-3", + UIDFrom: "u-3", + Quote: &protocol.TQuote{ + OwnerID: "u-2", + CliMsgID: "cli-2", + GlobalMsgID: "msg-2", + }, + }, "Chac chua?") + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-4", + CliMsgID: "cli-4", + UIDFrom: "u-4", + Quote: &protocol.TQuote{ + OwnerID: "u-3", + CliMsgID: "cli-3", + GlobalMsgID: "msg-3", + }, + }, "That?") + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-5", + CliMsgID: "cli-5", + UIDFrom: "u-5", + Quote: &protocol.TQuote{ + OwnerID: "u-4", + CliMsgID: "cli-4", + GlobalMsgID: "msg-4", + }, + }, "Reply 5") + ch.rememberReplyContext(threadID, "Cppai", protocol.TMessage{ + MsgID: "msg-6", + CliMsgID: "cli-6", + UIDFrom: "u-6", + Quote: &protocol.TQuote{ + OwnerID: "u-5", + CliMsgID: "cli-5", + GlobalMsgID: "msg-5", + }, + }, "Reply 6") + + got := ch.buildReplyContext(threadID, &protocol.TQuote{ + OwnerID: "u-6", + CliMsgID: "cli-6", + GlobalMsgID: "msg-6", + FromD: "Cppai", + Msg: "direct fallback", + }) + + for _, want := range []string{ + "Chao ban! Minh la CPPAI PM.", + "Anh Duc Day", + "Em biet anh la ai khong?", + "Chac chua?", + "That?", + "Reply 5", + "Reply 6", + } { + if !strings.Contains(got, want) { + t.Fatalf("missing %q in %q", want, got) + } + } + assertContainsInOrder(t, got, + "Chao ban! Minh la CPPAI PM.", + "Anh Duc Day", + "Em biet anh la ai khong?", + "Chac chua?", + "That?", + "Reply 5", + "Reply 6", + ) + if strings.Contains(got, "direct fallback") { + t.Fatalf("used direct fallback despite cache hit: %q", got) + } +} + func TestHandleDM_RejectedMessageDoesNotDownloadAttachment(t *testing.T) { var hits atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/channels/zalo/personal/reply-context.go b/internal/channels/zalo/personal/reply-context.go index e6682572..543249c0 100644 --- a/internal/channels/zalo/personal/reply-context.go +++ b/internal/channels/zalo/personal/reply-context.go @@ -19,10 +19,11 @@ func (c *Channel) rememberReplyContext(threadID, sender string, msg protocol.TMe return } c.replyCache.Store(c.replyScope(threadID), replycontext.Message{ - IDs: zaloMessageIDs(msg), - ParentIDs: zaloQuoteIDs(msg.Quote), - Sender: sender, - Body: body, + IDs: zaloMessageIDs(msg), + ParentIDs: zaloQuoteIDs(msg.Quote), + ParentQuote: zaloQuote(msg.Quote), + Sender: sender, + Body: body, }) }