Skip to content

feat(im): outbound rich messages renderer#645

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

feat(im): outbound rich messages renderer#645
ChrAlpha wants to merge 27 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 ChrAlpha force-pushed the feat/im-rich-outbound-renderers branch from 74128f4 to f32537d Compare June 15, 2026 12:31
ChrAlpha added 27 commits June 17, 2026 05:00
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.
Both flags lived in ChannelCapabilities but had zero consumers:
- Polls was not declared true by any adapter and had no validator or
  Send path.
- NativeCommands was checked nowhere; the only related code is the
  setMyCommands menu registration at boot (telegram.go:277), which is
  unconditional and doesn't gate on a capability flag.

Removing them keeps the cap matrix as a contract: every flag present
should have at least one adapter that flips it and at least one code
path that reads it. When poll / slash-command-suggestion features land
they'll bring back their own flags alongside Message-level schema.
…contract

Message.Thread was set by no inbound adapter (Slack captures thread_ts
into Conversation.ThreadID instead) and read by no outbound Send. Only
extractThreadID checked it as a fallback before falling through to the
canonical Conversation.ThreadID. Drop the field, the matching
ThreadRef type, the caps.Threads validator, and the chunk-propagation
test that asserted its dead propagation.

Conversation.ThreadID remains the single source of thread routing for
inbound ACL scoping. Slack outbound continues to derive thread_ts from
Reply.MessageID when replying within an existing thread; opening a new
thread is not a supported outbound operation today.

Forward stays on Message — it is populated by Telegram/Misskey inbound
and persisted to history — but a doc comment now records that no
outbound Send honors it, so future work that needs to forward an
existing message must design a dedicated path rather than relying on
the field being respected.
…on rendering

Mention rendering in all four Parts-aware adapters (Telegram, Discord,
Slack, Feishu) emitted only part.Text, so the canonical
ChannelIdentityID populated inbound for text_mention/at-tag/<@U…>/<@…>
entities was carried through the pipeline but never produced a real
platform ping on the way out. The LLM (or any author of a structured
mention) could not produce a clickable @-mention without bypassing the
schema.

Each adapter now consults part.ChannelIdentityID and emits its native
mention syntax when the ID matches the platform's safe character class:
- Discord: <@snowflake> (digits, plus & / ! / # for role/nick/channel)
- Slack:   <@U…> (uppercase alphanumeric — users, channels, subteams)
- Feishu:  <at user_id="ou_…"></at> (lowercase alphanumeric + _ -)
- Telegram: <a href="tg://user?id=N">name</a> (digits only)

IDs outside the safe class fall back to the existing inline-text path
so the visible mention still reaches the channel without forging a
ping. The canonical fixture (Mention with no ID → plain "@alice")
stays green; new test cases pin the id-resolved render and the unsafe
fallback per adapter.
telegramInlineKeyboard previously emitted only callback buttons via
NewInlineKeyboardButtonData(label, value) and silently skipped any
Action that lacked Value — so the canonical Action.URL field, present
in the schema and validated by the outbound layer, never produced a
clickable link button. That blocks any feature that wants to surface a
"Read more" / share / citation link in an IM ping (web_search results,
tool-call documentation links, ACP turn citations).

Extract a telegramActionButton helper that prefers a safe http(s) URL
over Value when both are set — a single tap can't carry both signals
and the URL gives the user immediate visible feedback. Unsafe schemes
(javascript:, data:, tg://) are rejected via isAllowedTelegramRichHref
so an attacker-supplied URL can't open a privileged app handler.
Actions with neither URL nor Value are still skipped.
Telegram's outbound Send/Update already call
renderTelegramMessagePartsRichMessage and route through the
sendRichMessage Bot API endpoint when msg.Message.Parts is non-empty.
But the Descriptor declared RichText:false, and coerceFormatForCaps
strips Parts on any non-RichText channel — collapsing them to a
markdown body before they could reach the adapter. The rich render
path was therefore unreachable in production and only exercised by
tests that bypass the manager.

Flip RichText to true. coerceFormatForCaps now preserves Parts; the
adapter's existing sendRichMessage / editRichMessage branches start
firing. A new TestCoerceFormatForCaps_PreservesPartsOnRichTextChannel
pins the invariant so this can't silently regress: if Parts ever stops
surviving coerce on a RichText:true channel, the adapter's rich
render becomes dead code again.
pushFinal collapsed every StreamEventFinal through
msg.Message.PlainText() + formatTelegramOutput, so a final whose
canonical Parts arrived intact (now that Telegram declares RichText:true
and coerceFormatForCaps preserves them) still reached the channel as
markdown→HTML text. Styled spans, mention identity, and structured
links delivered via streaming were silently degraded relative to the
non-streamed Send path.

Detect Parts in the final payload and route through a new pushFinalRich
helper: edit the in-flight stream message via editRichMessage when one
was opened during delta streaming, falling back to a fresh
sendRichMessage when the edit can't apply (e.g. the stream message was
a sendMessageDraft). Attachments still flow through the extracted
pushFinalAttachments helper. Bodies without Parts keep the existing
PlainText + formatTelegramOutput behaviour unchanged, so non-rich
streams keep their current edit/delta semantics.
- Slack/Discord stream finals now render through the Parts renderer instead
  of falling back to PlainText(), preserving inline styling.
- Telegram rich Parts fallback path emits HTML directly from Parts instead
  of going through a Markdown regex, avoiding accidental link/style
  injection from literal markdown in the source text.
- Telegram group streaming fallback persists parse_mode so subsequent edits
  keep HTML mode.
- Central stream chunker no longer splits rich Parts into plain chunks; rich
  finals stay on the prepared stream so the adapter renderer decides.
Resolves a conflict in the telegram adapter introduced by the migration
from go-telegram-bot-api/v5 to gopkg.in/telebot.v4 (PR memohai#666):

- telegram.go: port the new telegramActionButton helper (URL vs callback
  Value precedence) and isAllowedTelegramInlineKeyboardURL guard onto
  tele.InlineButton; drop the now-stale tgbotapi-typed sendDraftForTest
  shadow that the merge picked up from HEAD.
- rich_transport.go: switch sendRichMessage / editMessageText raw calls
  from tgbotapi.Params + bot.MakeRequest to map[string]any + bot.Raw,
  unwrapping the JSON {ok, result} envelope ourselves.
- message_rich.go: replace tgbotapi.ModeHTML with tele.ModeHTML for the
  HTML fallback parse mode.
- Test scaffolding (inline_keyboard_test.go, rich_transport_test.go,
  stream_test.go, telegram_test.go): rebuild the round-trip-intercepting
  bot via tele.NewBot(Settings{URL, Client, Offline:true}); parse
  intercepted request bodies as JSON instead of url.Values (telebot's
  Raw sends JSON); flip tele.InlineButton field assertions to use the
  string URL/Data fields instead of v5's *string.
telebot v4's ensureStreamMessage path emits Notify(Typing) → sendChatAction
before edits, which the round-trip stub treated as an unexpected method
and t.Fatalf'd on. Default the stub to a generic ok:true response so only
the assertions we care about (sendRichMessage 404 → sendMessage/
editMessageText with parse_mode=HTML) drive the test outcome.
@ChrAlpha ChrAlpha force-pushed the feat/im-rich-outbound-renderers branch from f32537d to 608dae9 Compare June 18, 2026 17:02
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