fix(zalo): preserve root quote in reply chains (#1331)

Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
Duc Nguyenandntduc authored and GitHub committed 2026-07-03 06:53:46 +07:00
1 parent 747b58d24c
commit c7652bce4d
6 files changed
+184 -22

No files matched your search

@@ -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 {
@@ -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"}
@@ -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 == "" {
@@ -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
@@ -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) {
@@ -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,
})
}