mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +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>
165 lines
5.2 KiB
Go
165 lines
5.2 KiB
Go
package lark
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
)
|
|
|
|
// flattenContent renders a Lark message's body.content — the raw,
|
|
// JSON-encoded string Lark double-encodes — into plain text, dispatching
|
|
// on msg_type. It is the shared structural step used by BOTH ingress
|
|
// paths:
|
|
//
|
|
// - the inbound decoder, for the user's own text / post message, and
|
|
// - the enricher, for the quoted-reply parent and merge_forward child
|
|
// messages it pulls back over the IM REST API.
|
|
//
|
|
// Mention placeholders (@_user_N) are preserved verbatim; the caller is
|
|
// responsible for resolving them against the message's mentions[] array
|
|
// via resolveMentions. The two ingress shapes (WS receive event vs IM
|
|
// REST item) carry the mentions array differently — only the caller
|
|
// knows which one applies — so flattening stays mention-agnostic.
|
|
//
|
|
// Non-text media types render as a stable bracketed placeholder so the
|
|
// agent sees that *something* was attached without us downloading the
|
|
// binary. Attachment ingestion is explicitly out of scope (tracked as a
|
|
// separate attachment-pipeline issue), and merge_forward is intercepted
|
|
// by the enricher before it reaches here (expanding it needs an HTTP
|
|
// round-trip); the inline placeholder is only a fallback for a forward
|
|
// nested inside another forward.
|
|
func flattenContent(msgType, rawContent string) string {
|
|
switch msgType {
|
|
case "text":
|
|
return extractTextBody(rawContent)
|
|
case "post":
|
|
return flattenPostContent(rawContent)
|
|
case "image":
|
|
return "[Image]"
|
|
case "file":
|
|
return "[File]"
|
|
case "audio":
|
|
return "[Audio]"
|
|
case "media":
|
|
return "[Video]"
|
|
case "sticker":
|
|
return "[Sticker]"
|
|
case "interactive":
|
|
return "[interactive card]"
|
|
case "share_chat":
|
|
return "[Shared Chat]"
|
|
case "share_user":
|
|
return "[Shared User Card]"
|
|
case "system":
|
|
return "[System Message]"
|
|
case "merge_forward":
|
|
return "[forwarded messages]"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// larkPostContent mirrors the RECEIVE-side shape of a `post` rich-text
|
|
// body.content. Crucially this is NOT the locale-wrapped form the SEND
|
|
// API takes ({"zh_cn": {...}}): an inbound post body.content unmarshals
|
|
// directly into {title, content}. content is a 2-D array — the outer
|
|
// array is the ordered list of paragraphs, each inner array the ordered
|
|
// spans of that paragraph; the newline between paragraphs is implicit in
|
|
// the array boundary, not a span.
|
|
type larkPostContent struct {
|
|
Title string `json:"title"`
|
|
Content [][]larkPostSpan `json:"content"`
|
|
}
|
|
|
|
// larkPostSpan is one node inside a post paragraph. Only the fields that
|
|
// carry renderable text are modelled; the tag set is extensible, so the
|
|
// flattener emits `text` for any unrecognized tag and skips it otherwise
|
|
// rather than failing.
|
|
type larkPostSpan struct {
|
|
Tag string `json:"tag"`
|
|
Text string `json:"text"`
|
|
Href string `json:"href"`
|
|
UserID string `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
}
|
|
|
|
// flattenPostContent flattens a received `post` body.content into plain
|
|
// text: the title (when present) on its own first line, then one line
|
|
// per paragraph. Within a paragraph spans are joined with a single space
|
|
// — this matches Lark's own rendering, where logically separate chunks
|
|
// ("Lark 集成", then a link "PR #3277") read as space-separated words.
|
|
//
|
|
// A link span renders as "text (href)" so the URL survives into the
|
|
// agent's context; an `at` span renders as its @_user_N placeholder (or
|
|
// the inline user_name when Lark already resolved it) so a downstream
|
|
// resolveMentions pass can substitute the display name. Media spans
|
|
// degrade to the same bracketed placeholders flattenContent uses.
|
|
func flattenPostContent(raw string) string {
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
var doc larkPostContent
|
|
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
write := func(line string) {
|
|
if b.Len() > 0 {
|
|
b.WriteByte('\n')
|
|
}
|
|
b.WriteString(line)
|
|
}
|
|
if doc.Title != "" {
|
|
write(doc.Title)
|
|
}
|
|
for _, para := range doc.Content {
|
|
write(flattenPostParagraph(para))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
func flattenPostParagraph(spans []larkPostSpan) string {
|
|
parts := make([]string, 0, len(spans))
|
|
for _, s := range spans {
|
|
switch s.Tag {
|
|
case "text", "code_block":
|
|
if s.Text != "" {
|
|
parts = append(parts, s.Text)
|
|
}
|
|
case "a":
|
|
switch {
|
|
case s.Text != "" && s.Href != "":
|
|
parts = append(parts, s.Text+" ("+s.Href+")")
|
|
case s.Text != "":
|
|
parts = append(parts, s.Text)
|
|
case s.Href != "":
|
|
parts = append(parts, s.Href)
|
|
}
|
|
case "at":
|
|
// Prefer an already-resolved display name; otherwise emit
|
|
// the user_id, which on the receive side is the @_user_N
|
|
// placeholder a later resolveMentions pass maps to a name.
|
|
switch {
|
|
case s.UserName != "":
|
|
parts = append(parts, "@"+s.UserName)
|
|
case s.UserID != "":
|
|
parts = append(parts, s.UserID)
|
|
}
|
|
case "img":
|
|
parts = append(parts, "[Image]")
|
|
case "media":
|
|
parts = append(parts, "[Video]")
|
|
case "emotion":
|
|
// emoji_type is an enum key (e.g. "SMILE"), not display
|
|
// text — skip it rather than leak the key.
|
|
case "hr":
|
|
parts = append(parts, "---")
|
|
default:
|
|
if s.Text != "" {
|
|
parts = append(parts, s.Text)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|