diff --git a/cli-config.yaml.example b/cli-config.yaml.example index e45132f0063..8d3525019c8 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -724,7 +724,7 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages -# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 +# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2 # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 6516c165401..0fede455a92 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter): self._mention_patterns = self._compile_mention_patterns() self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False) - # Bot API 10.1 Rich Messages: when explicitly enabled, send final - # replies via sendRichMessage with the raw agent markdown so - # tables/task lists/etc. render natively. Disabled by default because - # several Telegram clients accept but render rich messages poorly. - self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False) + # Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2 + # path degrades (tables → bullet lists, task lists,
, block + # math) via sendRichMessage / editMessageText's rich_message param using + # the raw agent markdown. Enabled by default; users can opt out for + # clients that accept but render rich messages poorly via + # platforms.telegram.extra.rich_messages: false. + self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True) # Latched off after a capability failure on sendRichMessage / # sendRichMessageDraft (e.g. older python-telegram-bot without the # endpoint) so later sends skip the doomed rich attempt entirely. @@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter): return True return False + def _needs_rich_rendering(self, content: str) -> bool: + """Return True for markdown constructs that the legacy path degrades. + + Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram + clients render a consistent font weight/spacing. The rich endpoint is + reserved for constructs where raw markdown materially improves output: + pipe tables (MarkdownV2 has no table syntax and rewrites them into + bullet lists), GFM task lists, collapsible ``
`` blocks, and + block math. Adapted from #45995 (@YonganZhang). + """ + if not content: + return False + if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()): + return True + if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content): + return True + if re.search(r"(?m)^|^", content): + return True + if "$$" in content: + return True + return False + + def _rich_eligible(self, content: str) -> bool: + """Capability/content eligibility for rich, ignoring ``expect_edits``. + + Shared core of :meth:`_should_attempt_rich` minus the per-call + ``expect_edits`` metadata gate. The rich EDIT-finalize path + (:meth:`_try_edit_rich`) needs this: a streamed preview is sent with + ``expect_edits=True`` to stay on the editable path mid-stream, but the + FINAL edit should still upgrade to rich when the content warrants it. + """ + return bool( + getattr(self, "_rich_messages_enabled", True) + and not getattr(self, "_rich_send_disabled", False) + and content + and content.strip() + and self._needs_rich_rendering(content) + and not self._has_telegram_desktop_details_math_crash_shape(content) + and self._content_fits_rich_limits(content) + and self._bot_supports_rich() + ) + def _should_attempt_rich( self, content: str, metadata: Optional[Dict[str, Any]] = None ) -> bool: return bool( - getattr(self, "_rich_messages_enabled", False) - and not getattr(self, "_rich_send_disabled", False) - and not (metadata or {}).get("expect_edits") - and content - and content.strip() - and not self._has_telegram_desktop_details_math_crash_shape(content) - and self._content_fits_rich_limits(content) - and self._bot_supports_rich() + not (metadata or {}).get("expect_edits") + and self._rich_eligible(content) ) def prefers_fresh_final_streaming( @@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter): ) -> bool: """Whether to replace a streamed preview with a fresh rich final. - Keep this disabled for Telegram. The fresh-final path briefly shows two - copies of the final answer, then deletes the streaming preview after the - rich send succeeds. That is especially visible on clients that support - rich messages well, and it looks like duplicate delivery at the end of - every streamed turn. Until Telegram rich edits are wired directly, final - streamed replies should edit the existing preview in place. + Disabled for Telegram. The fresh-final path briefly shows two copies of + the final answer, then deletes the streaming preview after the rich send + succeeds — it looks like duplicate delivery at the end of every streamed + turn (the reason #46206 reverted it). Rich finalize is instead handled + by editing the existing preview in place via Bot API 10.1's + ``editMessageText`` ``rich_message`` parameter (see + :meth:`_try_edit_rich`), so no fresh re-send / delete is needed. """ return False @@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter): streams split exactly as before. """ if ( - getattr(self, "_rich_messages_enabled", False) + getattr(self, "_rich_messages_enabled", True) and not getattr(self, "_rich_send_disabled", False) and self._bot_supports_rich() ): @@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter): message_id=str(message_id) if message_id is not None else None, ) + async def _try_edit_rich( + self, + chat_id: str, + message_id: str, + content: str, + ) -> Optional[SendResult]: + """Edit an existing message in place as a rich message (Bot API 10.1). + + Uses ``editMessageText`` with the ``rich_message`` parameter so a + streamed preview can finalize as rich (tables/task lists/details/math) + WITHOUT a fresh send + delete — no duplicate preview. Mirrors + :meth:`_try_send_rich`'s error contract: + + - success → ``SendResult(success=True, message_id=...)`` + - permanent / capability error → ``None`` (caller falls back to the + legacy MarkdownV2 edit; capability errors latch rich off) + - transient / unknown → ``SendResult(success=False)`` with retry + semantics (the message may already be edited; do NOT legacy-resend) + """ + payload: Dict[str, Any] = { + "chat_id": int(chat_id), + "message_id": int(message_id), + "rich_message": self._rich_message_payload(content), + } + if getattr(self, "_disable_link_previews", False): + payload["link_preview_options"] = {"is_disabled": True} + try: + # Raw Bot API result; do not request return_type=Message (PTB does + # not fully model the 10.1 response shape yet — a post-edit parse + # error must not be mistaken for a failed edit). + await self._bot.do_api_request("editMessageText", api_kwargs=payload) + except Exception as exc: + if self._is_rich_fallback_error(exc): + if self._is_rich_capability_error(exc): + self._rich_send_disabled = True + # "Message is not modified" — content identical to the current + # rich message; treat as a successful no-op so the caller does + # not fall through to a redundant legacy edit. + if "not modified" in str(exc).lower(): + return SendResult(success=True, message_id=message_id) + logger.debug( + "[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit", + self.name, exc, + ) + return None + if "not modified" in str(exc).lower(): + return SendResult(success=True, message_id=message_id) + err_str = str(exc).lower() + try: + from telegram.error import TimedOut as _TimedOut + except (ImportError, AttributeError): + _TimedOut = None + is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str + is_connect_timeout = self._looks_like_connect_timeout(exc) + logger.warning( + "[%s] rich editMessageText transient failure (no legacy resend): %s", + self.name, exc, + ) + return SendResult( + success=False, + error=str(exc), + retryable=(is_connect_timeout or not is_timeout), + ) + return SendResult(success=True, message_id=message_id) + def _should_attempt_rich_draft(self, content: str) -> bool: return bool( - getattr(self, "_rich_messages_enabled", False) + getattr(self, "_rich_messages_enabled", True) and not getattr(self, "_rich_send_disabled", False) and not getattr(self, "_rich_draft_disabled", False) and content @@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter): if not self._bot: return SendResult(success=False, error="Not connected") + # Rich finalize (Bot API 10.1): when the completed content has + # constructs the legacy MarkdownV2 edit degrades (tables → bullet + # lists, task lists,
, block math) and rich is available, + # edit the preview IN PLACE via editMessageText's rich_message param. + # No fresh send + delete → no duplicate preview (the problem #46206 + # reverted the fresh-final path for). Attempted before the 4,096 + # overflow pre-flight because the rich text cap is 32,768 — a rich + # table that exceeds the MarkdownV2 limit must not be split into legacy + # chunks. Falls back to the legacy edit path (overflow split included) + # on capability/permanent rejection. + if finalize and self._rich_eligible(content): + rich_result = await self._try_edit_rich(chat_id, message_id, content) + if rich_result is not None: + return rich_result + # Pre-flight: if content already exceeds the limit, split-and-deliver # without round-tripping a doomed edit. if utf16_len(content) > self.MAX_MESSAGE_LENGTH: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 3a098256204..f374055eace 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1991,7 +1991,7 @@ DEFAULT_CONFIG = { "channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group) "allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist) "extra": { - "rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 + "rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2 }, }, diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 0d5e2828a64..9e74dd355ad 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -832,7 +832,7 @@ class TestLoadGatewayConfig: assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False - def test_load_config_default_disables_telegram_rich_messages(self, tmp_path, monkeypatch): + def test_load_config_default_enables_telegram_rich_messages(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() @@ -842,7 +842,7 @@ class TestLoadGatewayConfig: config = load_config() - assert config["telegram"]["extra"]["rich_messages"] is False + assert config["telegram"]["extra"]["rich_messages"] is True def test_bridges_telegram_extra_base_url_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index 9b8d479f2a0..78832a41e25 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -61,6 +61,8 @@ def _make_adapter(extra=None): bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) bot.send_chat_action = AsyncMock() # keeps the post-send typing re-trigger quiet bot.send_message_draft = AsyncMock(return_value=True) # legacy draft fallback + bot.edit_message_text = AsyncMock(return_value=MagicMock(message_id=1)) # legacy edit path + bot.delete_message = AsyncMock(return_value=True) adapter._bot = bot return adapter @@ -184,7 +186,10 @@ async def test_rich_messages_opt_out_accepts_string_false(): @pytest.mark.asyncio -async def test_rich_messages_default_is_disabled(): +async def test_rich_messages_default_is_enabled(): + """Rich messages are on by default (Bot API 10.1); rich-eligible content + (tables/task lists/details/math) goes through sendRichMessage without the + user having to opt in.""" config = PlatformConfig(enabled=True, token="fake-token") adapter = TelegramAdapter(config) bot = MagicMock() @@ -195,6 +200,42 @@ async def test_rich_messages_default_is_disabled(): result = await adapter.send("12345", RICH_CONTENT) + assert result.success is True + bot = adapter._bot + assert bot is not None + bot.do_api_request.assert_awaited_once() + bot.send_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_rich_messages_can_be_opted_out(): + """Setting platforms.telegram.extra.rich_messages: false keeps every reply + on the legacy MarkdownV2 path even for rich-eligible content.""" + config = PlatformConfig( + enabled=True, token="fake-token", extra={"rich_messages": False} + ) + adapter = TelegramAdapter(config) + bot = MagicMock() + bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=123)) + bot.send_message = AsyncMock(return_value=MagicMock(message_id=1)) + bot.send_chat_action = AsyncMock() + adapter._bot = bot + + result = await adapter.send("12345", RICH_CONTENT) + + assert result.success is True + bot.do_api_request.assert_not_called() + bot.send_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_plain_markdown_stays_on_legacy_path(): + """Ordinary replies (no table/task-list/details/math) stay on the legacy + MarkdownV2 path for consistent client rendering, even with rich enabled.""" + adapter = _make_adapter() + + result = await adapter.send("12345", "Hello **there**\n\nA normal reply.") + assert result.success is True bot = adapter._bot assert bot is not None @@ -240,7 +281,9 @@ async def test_oversized_content_skips_rich_and_chunks(): async def test_rich_limit_is_characters_not_bytes(): """Telegram's rich limit is UTF-8 characters, not encoded bytes.""" adapter = _make_adapter() - cjk = "测" * 20000 # 20k chars, 60k UTF-8 bytes + # Rich-eligible (table) so the content takes the rich path; the CJK body + # is 20k chars / 60k UTF-8 bytes — over the byte count, under the char cap. + cjk = "| a | b |\n|---|---|\n" + "测" * 20000 # 20k chars, ~60k UTF-8 bytes assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS @@ -324,7 +367,9 @@ async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc): async def test_rich_payload_preserves_link_preview_disable(): adapter = _make_adapter(extra={"disable_link_previews": True}) - result = await adapter.send("12345", "See https://example.com") + result = await adapter.send( + "12345", "| Link | Note |\n|---|---|\n| See https://example.com | x |" + ) assert result.success is True api_kwargs = _rich_api_kwargs(adapter) @@ -575,3 +620,139 @@ async def test_rich_draft_opt_out_uses_legacy(): assert bot is not None bot.do_api_request.assert_not_called() bot.send_message_draft.assert_awaited_once() + + +# ---------------------------------------------------------------------------- +# Rich finalize via editMessageText (Bot API 10.1 rich_message edit param). +# Streamed previews finalize by editing the existing message IN PLACE as rich, +# so tables/task lists survive without a fresh send + delete (no duplicate). +# ---------------------------------------------------------------------------- + + +def _rich_edit_kwargs(adapter): + """Return the api_kwargs dict from the single editMessageText rich call.""" + call = adapter._bot.do_api_request.call_args + assert call.args[0] == "editMessageText" + return call.kwargs["api_kwargs"] + + +@pytest.mark.asyncio +async def test_finalize_edit_uses_rich_for_table_content(): + """Finalizing a streamed preview whose content is a table edits the + existing message IN PLACE via editMessageText's rich_message param — + no fresh send, no delete, no duplicate.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + assert result.message_id == "555" # same message, edited in place + api_kwargs = _rich_edit_kwargs(adapter) + assert api_kwargs["message_id"] == 555 + # RAW markdown is passed through so table pipes survive. + assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT + # No fresh send / delete — the whole point of the in-place rich edit. + adapter._bot.edit_message_text.assert_not_called() + adapter._bot.delete_message.assert_not_called() + + +@pytest.mark.asyncio +async def test_finalize_edit_plain_content_stays_legacy(): + """Finalizing plain content (no table/task-list/details/math) uses the + legacy MarkdownV2 edit_message_text path, not the rich edit endpoint.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", "Just a normal answer, no rich constructs.", finalize=True, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_capability_error_falls_back_to_legacy(): + """A capability error on the rich edit latches rich off and falls back to + the legacy MarkdownV2 edit so the user still gets the final answer.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock(side_effect=PTB_ENDPOINT_NOT_FOUND) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + assert adapter._rich_send_disabled is True + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_not_modified_is_success_noop(): + """'Message is not modified' on a rich edit is a no-op success — must NOT + fall through to a redundant legacy edit.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock( + side_effect=BadRequest("Message is not modified") + ) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + adapter._bot.edit_message_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_non_finalize_edit_never_uses_rich(): + """Intermediate (non-finalize) stream edits stay on the plain edit path; + rich is only applied on the final edit.""" + adapter = _make_adapter() + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=False, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_opt_out_uses_legacy(): + """With rich_messages: false, even a table finalizes via the legacy + MarkdownV2 edit path.""" + adapter = _make_adapter(extra={"rich_messages": False}) + + result = await adapter.edit_message( + "12345", "555", RICH_CONTENT, finalize=True, + ) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.edit_message_text.assert_awaited() + + +@pytest.mark.asyncio +async def test_finalize_edit_rich_over_markdownv2_limit_not_split(): + """A rich table that exceeds the 4,096 MarkdownV2 limit but fits the 32,768 + rich cap is edited in place as one rich message, NOT split into legacy + chunks.""" + adapter = _make_adapter() + big_table = "| a | b |\n|---|---|\n" + "\n".join( + f"| {'x' * 50} | {'y' * 50} |" for _ in range(40) + ) + assert len(big_table) > TelegramAdapter.MAX_MESSAGE_LENGTH + assert len(big_table) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS + + result = await adapter.edit_message( + "12345", "555", big_table, finalize=True, + ) + + assert result.success is True + api_kwargs = _rich_edit_kwargs(adapter) + assert api_kwargs["rich_message"]["markdown"] == big_table + adapter._bot.edit_message_text.assert_not_called() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index e52bfac9240..c255802bbb2 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -900,23 +900,23 @@ gateway: ## Rendering: Rich Messages, Tables and Link Previews -**Rich Messages (Bot API 10.1).** When opted in, final replies are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so tables, task lists, headings, nested blockquotes, collapsible `
`, footnotes/references, math/formulas, underline, sub/superscript, marked text, and anchors render natively — no client-side flattening. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. +**Rich Messages (Bot API 10.1).** Final replies that contain constructs the legacy MarkdownV2 path degrades — tables, task lists, collapsible `
`, and block math — are sent with Telegram's native [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) using the agent's **raw markdown**, so they render natively with no client-side flattening. During streaming, the final answer is delivered by **editing the existing preview in place** via `editMessageText`'s `rich_message` parameter — no second message, no delete, so there is no duplicate-delivery flicker at the end of a turn. In DMs the live streaming preview also uses `sendRichMessageDraft`, so the animated draft matches the final rich message. Ordinary replies (plain prose, bold/italic, simple lists) stay on the MarkdownV2 path for consistent font weight and spacing across clients. -The rich path is skipped automatically when content exceeds the 32,768-byte rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message). +The rich path is skipped automatically when content exceeds the 32,768-character rich text limit, and any rejection from Telegram (unsupported endpoint on an older `python-telegram-bot`, parser error, oversized blocks/columns) **transparently falls back** to the MarkdownV2 path — your message is never lost. Transient/network errors are *not* silently re-sent (no duplicate final message). **MarkdownV2 fallback.** When the rich path is unavailable for a message, Hermes converts markdown to MarkdownV2. Since MarkdownV2 has no native table syntax, pipe tables are normalized: - **Small tables** are flattened into **row-group bullets** — each row becomes a readable bulleted list under the column headings. Good for 2–4 columns and short cells. - **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses. -Rich messages are disabled by default because some Telegram clients accept the Bot API payload but render it poorly. To opt in for clients that handle rich messages well: +Rich messages are **enabled by default**. Some Telegram clients accept the Bot API payload but render it poorly; to opt out and force every reply onto the legacy MarkdownV2 path: ```yaml gateway: platforms: telegram: extra: - rich_messages: true + rich_messages: false ``` This setting is for client-rendering compatibility; Hermes already falls back automatically when Telegram rejects the rich API call. If you only want the legacy "always code-block" table behavior while keeping rich messages enabled, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`). diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md index 06dd22e694b..facbb23da13 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md @@ -877,23 +877,23 @@ gateway: ## 渲染:富消息、表格和链接预览 -**富消息(Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。 +**富消息(Bot API 10.1)。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `
` 以及块级数学公式——会通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,从而原生渲染、无需客户端展平。在流式传输过程中,最终答案通过 `editMessageText` 的 `rich_message` 参数**就地编辑现有预览**来交付——不发第二条消息、不删除,因此一轮结束时不会出现重复投递的闪烁。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。普通回复(纯文本、粗体/斜体、简单列表)仍走 MarkdownV2 路径,以在各客户端保持一致的字重和间距。 -当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 +当内容超过 32,768 字符的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 **MarkdownV2 回退。** 当某条消息无法使用富消息路径时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化: - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。 - **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。 -富消息默认关闭,因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息,可以选择启用: +富消息**默认启用**。一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差;若要关闭并强制所有回复走旧版 MarkdownV2 路径: ```yaml gateway: platforms: telegram: extra: - rich_messages: true + rich_messages: false ``` 这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。