feat(im): outbound rich messages renderer#645
Draft
ChrAlpha wants to merge 27 commits into
Draft
Conversation
74128f4 to
f32537d
Compare
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.
f32537d to
608dae9
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