feat(im): outbound rich messages renderer#645
Draft
ChrAlpha wants to merge 23 commits into
Draft
Conversation
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.
74128f4 to
f32537d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
基于 #644 能力,补齐 outbound 富文本能力的渲染层: LLM 产生的 canonical MessagePart → 各平台 native 富格式(Telegram HTML / Discord GFM / Slack mrkdwn / Feishu lark_md),或在能力不足的通道上自动降级为 Markdown / Plain。本 PR 只落地能力支持,不翻转任何实际行为(后续额外单独考虑)。
Boundary
*b*/_i_/<url|text>)Tests