mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 20:15:37 +02:00
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>
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|