Files
multica/server/internal/integrations/lark/content_flatten.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

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