mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
fix(zalo): preserve root quote in reply chains (#1331)
Co-authored-by: ntduc <ntduc@cpp.ai.vn>
This commit is contained in:
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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user