Files
multica/server/internal/integrations/lark/content_flatten_test.go
Bohan Jiang 5eba94ee25 feat(lark): inbound context enrichment — post / merge_forward / quoted-reply (MUL-2951) (#3724)
Expand an inbound Lark bot message's body before dispatch with the context
a user explicitly attached, so the agent sees a semantically complete
conversation instead of a bare "@bot 总结一下".

- post: flatten rich-text (title + paragraphs, links, @-mentions) to plain
  text synchronously in the decoder.
- merge_forward: inline the forwarded transcript via a single GetMessage —
  GET /open-apis/im/v1/messages/{id} returns the forward sentinel plus the
  bundled children. (The issue's container_id_type=merge_forward query is
  undocumented; this avoids it and also handles a forwarded quoted parent.)
- quoted reply: prepend the parent_id message as a <quoted_message> block;
  a parent that is itself a forward nests a <forwarded_messages> block.
- new InboundEnricher runs in the WS connector between decode and emit,
  bounded by EnrichTimeout and degrading to "[unable to fetch]" placeholders
  so it never blocks the ~3s long-conn ACK budget.

/issue stays parseable on a quote-reply by parsing the command from the
user's own text (CommandBody) rather than the enriched body.

Short-window debounce batching (issue item #4) is tracked as a follow-up.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 11:58:16 +08:00

102 lines
3.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lark
import "testing"
// TestFlattenPostContent_IssueExample pins the exact rich-text `post`
// example from MUL-2951: a title line, a prose paragraph, and a
// paragraph mixing a text span with a hyperlink span. The link must
// render as "text (href)" so the URL survives into the agent's context.
func TestFlattenPostContent_IssueExample(t *testing.T) {
t.Parallel()
// Received-side post body.content (NOT locale-wrapped).
raw := `{
"title": "周报",
"content": [
[{ "tag": "text", "text": "本周完成:" }],
[
{ "tag": "text", "text": "Lark 集成" },
{ "tag": "a", "href": "https://github.com/multica-ai/multica/pull/3277", "text": "PR #3277" }
]
]
}`
want := "周报\n本周完成\nLark 集成 PR #3277 (https://github.com/multica-ai/multica/pull/3277)"
if got := flattenPostContent(raw); got != want {
t.Errorf("flattenPostContent()\n got = %q\nwant = %q", got, want)
}
}
func TestFlattenPostContent_NoTitle(t *testing.T) {
t.Parallel()
raw := `{"content":[[{"tag":"text","text":"line one"}],[{"tag":"text","text":"line two"}]]}`
want := "line one\nline two"
if got := flattenPostContent(raw); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestFlattenPostContent_MediaAndMentionSpans(t *testing.T) {
t.Parallel()
// at span carries the @_user_N placeholder (resolved later by
// resolveMentions); media tags degrade to bracket placeholders;
// emotion is skipped entirely.
raw := `{"content":[[
{"tag":"at","user_id":"@_user_1","user_name":""},
{"tag":"text","text":"look"},
{"tag":"img","image_key":"img_x"},
{"tag":"emotion","emoji_type":"SMILE"}
]]}`
want := "@_user_1 look [Image]"
if got := flattenPostContent(raw); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestFlattenPostContent_AtPrefersResolvedName(t *testing.T) {
t.Parallel()
raw := `{"content":[[{"tag":"at","user_id":"@_user_1","user_name":"Tom"},{"tag":"text","text":"hi"}]]}`
want := "@Tom hi"
if got := flattenPostContent(raw); got != want {
t.Errorf("got %q want %q", got, want)
}
}
func TestFlattenPostContent_Malformed(t *testing.T) {
t.Parallel()
if got := flattenPostContent("not json"); got != "" {
t.Errorf("malformed content should flatten to empty, got %q", got)
}
if got := flattenPostContent(""); got != "" {
t.Errorf("empty content should flatten to empty, got %q", got)
}
}
func TestFlattenContent_DispatchByType(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msgType string
content string
want string
}{
{"text", "text", `{"text":"hello"}`, "hello"},
{"image", "image", `{"image_key":"img_x"}`, "[Image]"},
{"file", "file", `{"file_key":"f"}`, "[File]"},
{"audio", "audio", `{"file_key":"f"}`, "[Audio]"},
{"media", "media", `{"file_key":"f"}`, "[Video]"},
{"sticker", "sticker", `{"file_key":"f"}`, "[Sticker]"},
{"interactive", "interactive", `{"title":"t"}`, "[interactive card]"},
{"share_chat", "share_chat", `{"chat_id":"oc"}`, "[Shared Chat]"},
{"merge_forward", "merge_forward", `{"content":"Merged and Forwarded Message"}`, "[forwarded messages]"},
{"unknown", "totally_new_type", `{}`, ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := flattenContent(tc.msgType, tc.content); got != tc.want {
t.Errorf("flattenContent(%q) = %q want %q", tc.msgType, got, tc.want)
}
})
}
}