Skip to content

feat(im): outbound rich messages renderer#645

Draft
ChrAlpha wants to merge 23 commits into
memohai:mainfrom
ChrAlpha:feat/im-rich-outbound-renderers
Draft

feat(im): outbound rich messages renderer#645
ChrAlpha wants to merge 23 commits into
memohai:mainfrom
ChrAlpha:feat/im-rich-outbound-renderers

Conversation

@ChrAlpha

Copy link
Copy Markdown
Member

Summary

基于 #644 能力,补齐 outbound 富文本能力的渲染层: LLM 产生的 canonical MessagePart → 各平台 native 富格式(Telegram HTML / Discord GFM / Slack mrkdwn / Feishu lark_md),或在能力不足的通道上自动降级为 Markdown / Plain。本 PR 只落地能力支持,不翻转任何实际行为(后续额外单独考虑)。

Boundary

Platform 富格式 Parts 降级 Markdown 降级 Plain
Telegram ✅ sendRichMessage HTML
Discord ✅ GFM masked link + style + fence
Slack ✅ mrkdwn (*b* / _i_ / <url|text>)
Feishu ✅ lark_md
Matrix
DingTalk
WeChat / WeCom

Tests

  • Per-renderer:Telegram / Discord / Feishu / Slack 各自 message_rich + URL/escape 加固用例
  • Canonical fixture(internal/channel/partsfixture)跨 4 适配器矩阵 + coerceFormatForCaps 的三层 capability 降级覆盖
  • mise exec -- go test ./internal/channel/... -count=1
  • golangci-lint run ./... 0 issues

@ChrAlpha ChrAlpha changed the title feat(pipeline): consume inbound MessagePart in adapt feat(im): outbound rich messages renderer Jun 15, 2026
ChrAlpha added 23 commits June 15, 2026 12:11
Adapters that populate Message.Parts (currently only Telegram, for mentions)
had their structured data silently dropped: adaptMessage and adaptEdit only
read msg.Message.Text, so the canonical ContentNode tree the renderer is
designed to consume was never built.

Add adaptBody/adaptParts/partToNode that translate channel.MessagePart into
the ContentNode shape the renderer already supports (mention/link/pre/code
plus nested bold/italic/strikethrough wrappers). Fall back to the existing
plain-text path when Parts is empty.

Telegram inbound mentions now reach the LLM as <mention uid="..."> instead
of being flattened to plain text.
The previous extractor only recognised mention/text_mention, so Telegram
formatting (bold, italic, code, code blocks, text_link, bare URLs) was
flattened to plain text before reaching the pipeline. With the renderer
now consuming MessagePart via adaptBody, populating the full entity set
lets the LLM see the user's formatting intent.

The new extractor walks rune offsets, sorts entities by (offset asc,
length desc) so an outer span wins on overlap, emits plain-text Parts
for the gaps between entities, and returns nil when the result carries
no rich content so callers fall back to the Text field unchanged.
Feishu post messages already carry a structured tree (lines of typed
elements: text with style, link, at-mention, code block). The inbound
path flattened it to a space-joined string, so style, link URLs, and
@user IDs never reached the pipeline.

extractFeishuPostParts walks the same lines/parts structure as the
existing text and attachment extractors, translates each tag (text +
style array, a, at, code_block) into channel.MessagePart, and inserts
a newline text part between lines so the LLM sees the paragraph break.
Returns nil for single-line all-unstyled posts so callers fall back to
the plain Text field unchanged.
discordgo delivers raw Markdown in m.Content, so the LLM only ever saw
the literal asterisks/underscores users typed. With the pipeline now
consuming Parts via adaptBody, parse the common Discord-flavor markdown
into the canonical schema so bold/italic/strike/code/links/mentions are
preserved end-to-end.

parseDiscordMessageParts walks left-to-right, trying code-fence, inline
code, link, mention, then delimited style patterns in priority order.
A backslash before a sigil disables detection at that position so
literal \*foo\* stays plain. Returns nil when no rich content is found
so the existing plain-Text fallback path is unchanged.

Wired into the MessageCreate handler; Format is bumped to rich whenever
Parts populate.
Slack delivers raw mrkdwn in ev.Text, so formatting (single-asterisk
bold, _italic_, ~strike~), inline code, code blocks, and angle-bracket
tokens (<@U…>, <#C…|name>, <url|label>, <!here>) reached the LLM as
literal characters instead of structured context.

parseSlackMessageParts is a small left-to-right tokenizer covering the
mrkdwn subset users actually type. Angle tokens are dispatched to one
helper that pivots on the first byte (@ user, # channel, ! special,
otherwise URL link). Styled spans use single-character delimiters
(differing from Discord's **bold**/~~strike~~ doubles).

Wired into the regular message handler and the app_mention handler so
both inbound paths produce identical Parts. Format is bumped to rich
whenever Parts populate.
Telegram entity offset and length are documented as UTF-16 code units,
but the parser indexed into []rune, which under-counts every
supplementary-plane character (most emoji) by one position. A bold
entity following 🎉 in the message would land one character left of
its real target, so the LLM would see "old" wrapped in <b> instead of
"bold", and any further entity in the same message would compound the
drift.

Switch the parser to walk utf16.Encode/Decode of the text, matching
the Telegram spec. The BMP-only test is kept (CJK still indexes the
same under either model) and the surrogate-pair case is now covered by
TestExtractTelegramMessageParts_HandlesSupplementaryPlaneEmoji.
toInboundTelegramMessage hard-coded MessageFormatPlain regardless of
whether entity parsing produced rich Parts, so an inbound message with
bold/italic/code spans reached downstream consumers tagged as plain
even though Parts already carried the structure. Discord/Slack/Feishu
inbound flip Format to Rich in the same condition; align Telegram.

Also rename the local variable from mentionParts to richParts now that
the extractor covers all supported entity types, not just mentions.
Adds a rich-message transport layer (sendRichMessage / editMessageText with
rich_message) and a Parts → HTML renderer that emits <b>/<i>/<a>/<pre> etc.
Wired into Send/Update with plain-text fallback when Parts are absent.

RichText capability stays off here so coerceFormatForCaps continues to degrade
Parts upstream; renderer is inert in production until the flag flips in a
separate stage.
Adds renderDiscordMessagePartsMarkdown emitting masked links, code fences and
style markers. Wired into sendDiscordMessage with plain-text fallback.

RichText capability stays off; renderer is inert until the flag flips.
Adds renderSlackMessagePartsMrkdwn emitting *bold*, _italic_, <url|text> and
fenced code via the slack mrkdwn dialect. Wired into sendSlackMessage with
plain-text fallback.

RichText capability stays off; renderer is inert until the flag flips.
- Remove discordEscapeLinkText scaffolding shadowed by the more thorough
  escapeDiscordLinkText introduced in the hardening pass.
- Rename selectBacktickFence's min parameter to minRun to avoid shadowing the
  predeclared builtin.
Same as upstream 4a2afac but on the renderer-only branch the escape lives in
rich_escape.go (the toolcall_embed.go file does not exist here).
…ine escaper

Have escapeDiscordLinkText / escapeFeishuLinkText delegate to the package
inline-markdown escaper to keep the link-text escape table aligned with style
wrappers in one place. Adapter behavior unchanged.
@ChrAlpha ChrAlpha force-pushed the feat/im-rich-outbound-renderers branch from 74128f4 to f32537d Compare June 15, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant