diff --git a/cli-config.yaml.example b/cli-config.yaml.example index fd45f429190..e45132f0063 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: true # Set false to force legacy MarkdownV2 for clients that cannot render Bot API rich messages +# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/config.py b/gateway/config.py index f11146e606a..ae149f81dc0 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -417,9 +417,9 @@ class StreamingConfig: # if the original preview has been visible for at least this many # seconds, so the platform's visible timestamp reflects completion # time instead of the preview creation time. Currently applied to - # Telegram only (other platforms ignore the setting). Default 60s - # matches the OpenClaw rollout. Set to 0 to disable. - fresh_final_after_seconds: float = 60.0 + # Telegram only (other platforms ignore the setting). Default 0 disables + # the fresh-message replacement path; set >0 to opt in. + fresh_final_after_seconds: float = 0.0 def to_dict(self) -> Dict[str, Any]: return { @@ -446,7 +446,7 @@ class StreamingConfig: ), cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR), fresh_final_after_seconds=_coerce_float( - data.get("fresh_final_after_seconds"), 60.0 + data.get("fresh_final_after_seconds"), 0.0 ), ) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 06a231f092b..6516c165401 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -419,11 +419,11 @@ 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: send final replies via sendRichMessage - # with the raw agent markdown so tables/task lists/etc. render natively. - # Enabled by default; users can opt out for clients that accept but do - # not render rich messages via platforms.telegram.extra.rich_messages. - self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True) + # 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) # 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. @@ -983,7 +983,7 @@ class TelegramAdapter(BasePlatformAdapter): self, content: str, metadata: Optional[Dict[str, Any]] = None ) -> bool: return bool( - getattr(self, "_rich_messages_enabled", True) + getattr(self, "_rich_messages_enabled", False) and not getattr(self, "_rich_send_disabled", False) and not (metadata or {}).get("expect_edits") and content @@ -996,23 +996,16 @@ class TelegramAdapter(BasePlatformAdapter): def prefers_fresh_final_streaming( self, content: str, metadata: Optional[Dict[str, Any]] = None ) -> bool: - """Finalize rich-eligible streamed replies with a fresh sendRichMessage - instead of Hermes' current MarkdownV2 edit path. + """Whether to replace a streamed preview with a fresh rich final. - The final edit path has not yet been upgraded to Bot API 10.1's - ``rich_message`` edit parameter, so finalizing through edit would lose - rich constructs such as tables/task lists. When the completed content - is rich-eligible, re-send it via ``sendRichMessage`` and delete the - preview (see ``gateway.stream_consumer._try_fresh_final``). - - ``metadata`` is intentionally ignored: the preview was sent with - ``expect_edits=True`` (to stay on the editable path mid-stream), but the - FINAL answer is a brand-new message that should render rich. Gating - otherwise matches :meth:`_should_attempt_rich`: rich not latched off, - content present and within the rich character limit, and the bot exposes - an async ``do_api_request``. + 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. """ - return self._should_attempt_rich(content) + return False def streaming_overflow_limit(self) -> Optional[int]: """Allow the stream consumer to accumulate up to the rich-message cap @@ -1026,7 +1019,7 @@ class TelegramAdapter(BasePlatformAdapter): streams split exactly as before. """ if ( - getattr(self, "_rich_messages_enabled", True) + getattr(self, "_rich_messages_enabled", False) and not getattr(self, "_rich_send_disabled", False) and self._bot_supports_rich() ): @@ -1216,7 +1209,7 @@ class TelegramAdapter(BasePlatformAdapter): def _should_attempt_rich_draft(self, content: str) -> bool: return bool( - getattr(self, "_rich_messages_enabled", True) + getattr(self, "_rich_messages_enabled", False) and not getattr(self, "_rich_send_disabled", False) and not getattr(self, "_rich_draft_disabled", False) and content diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index aefacdbd4f7..7fc9846c49f 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -635,6 +635,15 @@ class GatewayStreamConsumer: ) if self._final_response_sent: self._final_content_delivered = True + elif self._fallback_final_send: + # The final edit attempt itself may be the one + # that exhausts flood-control strikes and + # promotes the consumer into fallback mode. Do + # not return to the gateway with a full-response + # fallback still pending; send only the unsent + # tail here so the normal gateway send path does + # not duplicate the visible prefix. + await self._send_fallback_final(self._accumulated) elif not self._already_sent: self._final_response_sent = await self._send_or_edit(self._accumulated) if self._final_response_sent: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7bb0b283035..7ee1f8690c6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1990,7 +1990,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": True, # Bot API 10.1 rich messages; set false to force legacy MarkdownV2 + "rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2 }, }, @@ -2327,7 +2327,7 @@ DEFAULT_CONFIG = { # delivered as a fresh message if the preview has been visible at # least this many seconds, so the platform timestamp reflects # completion time. Telegram only; other platforms ignore it. - "fresh_final_after_seconds": 60.0, + "fresh_final_after_seconds": 0.0, }, # Session storage — controls automatic cleanup of ~/.hermes/state.db. diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index cae1d8e13e1..0d5e2828a64 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -186,7 +186,7 @@ class TestStreamingConfig: ) assert restored.edit_interval == 0.8 assert restored.buffer_threshold == 24 - assert restored.fresh_final_after_seconds == 60.0 + assert restored.fresh_final_after_seconds == 0.0 class TestGatewayConfigRoundtrip: @@ -832,7 +832,7 @@ class TestLoadGatewayConfig: assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False - def test_load_config_default_includes_telegram_rich_messages(self, tmp_path, monkeypatch): + def test_load_config_default_disables_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 True + assert config["telegram"]["extra"]["rich_messages"] is False 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_stream_consumer.py b/tests/gateway/test_stream_consumer.py index af012fb69a7..eb867300640 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -993,17 +993,16 @@ class TestFinalContentDeliveredGuard: requiring a second finalize edit even when content is unchanged.""" adapter = MagicMock() adapter.REQUIRES_EDIT_FINALIZE = True # Telegram adapter behavior - # First send (initial streaming message) succeeds - # Mid-stream finalize edit succeeds - # Final finalize edit FAILS (e.g. flood control on Telegram) - adapter.edit_message = AsyncMock(side_effect=[ - SimpleNamespace(success=True), # mid-stream edit - SimpleNamespace(success=True), # finalize edit on line 548 - SimpleNamespace(success=False), # final finalize on line 580 (FAILS) + # First send (initial streaming message) succeeds. + # Mid-stream edit succeeds. + # Final finalize edit fails, and the consumer's own fallback send also + # fails, so no path has confirmed the complete final response reached + # the user. + adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=False)) + adapter.send = AsyncMock(side_effect=[ + SimpleNamespace(success=True, message_id="msg_1"), + SimpleNamespace(success=False, error="network down"), ]) - adapter.send = AsyncMock( - return_value=SimpleNamespace(success=True, message_id="msg_1"), - ) adapter.MAX_MESSAGE_LENGTH = 4096 config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5) @@ -1013,6 +1012,10 @@ class TestFinalContentDeliveredGuard: consumer.on_delta("Part one of the response...\n") task = asyncio.create_task(consumer.run()) await asyncio.sleep(0.05) + # Keep the second delta buffered until finish so the complete answer is + # not already visible before the final edit attempt fails. + consumer.cfg.buffer_threshold = 10_000 + consumer._current_edit_interval = 10.0 consumer.on_delta("Part two, the complete final answer.\n") await asyncio.sleep(0.05) diff --git a/tests/gateway/test_stream_consumer_fresh_final.py b/tests/gateway/test_stream_consumer_fresh_final.py index 82e9de1f321..ed934969432 100644 --- a/tests/gateway/test_stream_consumer_fresh_final.py +++ b/tests/gateway/test_stream_consumer_fresh_final.py @@ -617,15 +617,15 @@ class TestStreamConsumerConfigFreshFinalField: class TestStreamingConfigFreshFinalField: """The gateway-level StreamingConfig carries the setting.""" - def test_default_enables_with_60s(self): + def test_default_is_disabled(self): from gateway.config import StreamingConfig cfg = StreamingConfig() - assert cfg.fresh_final_after_seconds == 60.0 + assert cfg.fresh_final_after_seconds == 0.0 def test_from_dict_uses_default_when_missing(self): from gateway.config import StreamingConfig cfg = StreamingConfig.from_dict({"enabled": True}) - assert cfg.fresh_final_after_seconds == 60.0 + assert cfg.fresh_final_after_seconds == 0.0 def test_from_dict_respects_explicit_zero(self): from gateway.config import StreamingConfig diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index d13dc43ef03..9b8d479f2a0 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -48,7 +48,11 @@ PTB_INVALID_TOKEN_404 = InvalidToken( def _make_adapter(extra=None): """Build a TelegramAdapter with a mock bot wired for the rich path.""" - config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {}) + config = PlatformConfig( + enabled=True, + token="fake-token", + extra={"rich_messages": True, **(extra or {})}, + ) adapter = TelegramAdapter(config) bot = MagicMock() # do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True, @@ -180,16 +184,22 @@ async def test_rich_messages_opt_out_accepts_string_false(): @pytest.mark.asyncio -async def test_rich_messages_default_is_enabled(): - adapter = _make_adapter() +async def test_rich_messages_default_is_disabled(): + config = PlatformConfig(enabled=True, token="fake-token") + 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 = adapter._bot assert bot is not None - bot.do_api_request.assert_awaited_once() - bot.send_message.assert_not_called() + bot.do_api_request.assert_not_called() + bot.send_message.assert_awaited() @pytest.mark.asyncio @@ -519,14 +529,13 @@ async def test_rich_draft_oversized_uses_legacy(): # ---------------------------------------------------------------------- -# prefers_fresh_final_streaming: the stream consumer asks the adapter whether -# to finalize a streamed reply by sending a fresh (rich) message + deleting the -# preview, instead of final-editing the preview through the non-rich edit path. -# Telegram opts in exactly when the content is rich-eligible. +# prefers_fresh_final_streaming: Telegram keeps streamed finals on the edit +# path, even when rich messages are enabled, so users do not briefly see two +# copies of the answer while the preview cleanup delete races the fresh send. # ---------------------------------------------------------------------- -def test_prefers_fresh_final_streaming_when_rich_enabled(): +def test_prefers_fresh_final_streaming_stays_disabled_when_rich_enabled(): adapter = _make_adapter() - assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True + assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is False def test_prefers_fresh_final_streaming_honors_rich_opt_out(): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 25ac4fedd3b..e22d143ce30 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1486,7 +1486,7 @@ streaming: edit_interval: 0.3 # Seconds between message edits buffer_threshold: 40 # Characters before forcing an edit flush cursor: " ▉" # Cursor shown during streaming - fresh_final_after_seconds: 60 # Send fresh final (Telegram) when preview is this old; 0 = always edit in place + fresh_final_after_seconds: 0 # Opt in to fresh final (Telegram) when preview is this old ``` When enabled, the bot sends a message on the first token, then progressively edits it as more tokens arrive. Platforms that don't support message editing (Signal, Email, Home Assistant) are auto-detected on the first attempt — streaming is gracefully disabled for that session with no flood of messages. @@ -1495,7 +1495,7 @@ For separate natural mid-turn assistant updates without progressive token editin **Overflow handling:** If the streamed text exceeds the platform's message length limit (~4096 chars), the current message is finalized and a new one starts automatically. -**Fresh final (Telegram):** Telegram's `editMessageText` preserves the original message timestamp, so a long-running streamed reply would keep the first-token timestamp even after completion. When `fresh_final_after_seconds > 0` (default `60`), the completed reply is delivered as a brand-new message (with the stale preview best-effort deleted) so Telegram's visible timestamp reflects completion time. Short previews still finalize in place. Set to `0` to always edit in place. +**Fresh final (Telegram):** Telegram's `editMessageText` preserves the original message timestamp, so a long-running streamed reply would keep the first-token timestamp even after completion. Set `fresh_final_after_seconds > 0` to opt in to delivering old previews as brand-new final messages with best-effort preview deletion. The default is `0`, which always finalizes streamed replies in place and avoids the brief duplicate-message/delete sequence on clients that show both operations. :::note Per-platform streaming defaults The master `streaming.enabled` switch is `false` by default — nothing streams until you flip it. Once enabled, streaming is decided **per platform**: Telegram ships with `display.platforms.telegram.streaming: true` (streams) and Discord with `display.platforms.discord.streaming: false` (does not). So after enabling streaming, Telegram streams out of the box and Discord stays on whole-message replies until you change its toggle. You can adjust these per-platform switches from the dashboard's **Channels** toggles or directly in `~/.hermes/config.yaml`. diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index 3f35d1689cb..e52bfac9240 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -900,7 +900,7 @@ gateway: ## Rendering: Rich Messages, Tables and Link Previews -**Rich Messages (Bot API 10.1).** 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).** 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. 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). @@ -909,17 +909,17 @@ The rich path is skipped automatically when content exceeds the 32,768-byte rich - **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. -There's nothing to configure for the API fallback — the adapter picks the right rendering per message. If a Telegram client accepts but cannot render rich messages (for example, a watch client that shows them as opaque media blocks), opt out and force the MarkdownV2 path: +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: ```yaml gateway: platforms: telegram: extra: - rich_messages: false + rich_messages: true ``` -Rich messages are enabled by default (`rich_messages: true`). 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`). +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`). **Link previews.** Telegram auto-generates link previews for URLs in bot messages. If you'd rather suppress those (long `/tools` output, agent reply that mentions ten links, etc.): diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index ac7adc3efd7..140057af1a9 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1302,7 +1302,7 @@ streaming: edit_interval: 0.3 # 消息编辑之间的秒数 buffer_threshold: 40 # 强制编辑刷新前的字符数 cursor: " ▉" # 流式传输期间显示的光标 - fresh_final_after_seconds: 60 # 当预览超过此时间时发送新的最终消息(Telegram);0 = 始终就地编辑 + fresh_final_after_seconds: 0 # 预览超过此时间时选择发送新的最终消息(Telegram) ``` 启用后,bot 在第一个 token 时发送消息,然后随着更多 token 到来渐进式编辑它。不支持消息编辑的平台(Signal、Email、Home Assistant)在第一次尝试时自动检测 —— 该会话的流式传输被优雅地禁用,不会产生大量消息。 @@ -1311,7 +1311,7 @@ streaming: **溢出处理:** 如果流式传输的文本超过平台的消息长度限制(约 4096 字符),当前消息被最终化,新消息自动开始。 -**新的最终消息(Telegram):** Telegram 的 `editMessageText` 保留原始消息时间戳,因此长时间运行的流式回复即使在完成后也会保留第一个 token 的时间戳。当 `fresh_final_after_seconds > 0`(默认 `60`)时,完成的回复作为全新消息传递(尽力删除旧预览),以便 Telegram 的可见时间戳反映完成时间。短预览仍然就地最终化。设置为 `0` 以始终就地编辑。 +**新的最终消息(Telegram):** Telegram 的 `editMessageText` 保留原始消息时间戳,因此长时间运行的流式回复即使在完成后也会保留第一个 token 的时间戳。设置 `fresh_final_after_seconds > 0` 可选择将旧预览作为全新的最终消息传递,并尽力删除旧预览。默认值为 `0`,始终就地最终化流式回复,避免某些客户端短暂显示重复消息再删除其中一条。 :::note 主开关 `streaming.enabled` 默认为 `false`——在你启用之前不会有任何流式传输。启用后,是否流式传输按**平台**决定:Telegram 默认带有 `display.platforms.telegram.streaming: true`(流式传输),Discord 为 `display.platforms.discord.streaming: false`(不流式传输)。因此启用流式传输后,Telegram 开箱即用地流式传输,Discord 在你修改其开关之前仍使用整条消息回复。你可以在仪表盘的 **Channels** 开关中或直接在 `~/.hermes/config.yaml` 中调整这些按平台的开关。 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 df92624dd73..06dd22e694b 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,7 +877,7 @@ gateway: ## 渲染:富消息、表格和链接预览 -**富消息(Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。 +**富消息(Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。 当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 @@ -886,17 +886,17 @@ gateway: - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。 - **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。 -API 回退无需配置——适配器会为每条消息选择正确的渲染方式。如果某个 Telegram 客户端能接收但不能渲染富消息(例如手表客户端把它们显示为不透明媒体块),可以选择退出并强制使用 MarkdownV2 路径: +富消息默认关闭,因为一些 Telegram 客户端能接收 Bot API 载荷但渲染效果很差。若你的客户端能良好处理富消息,可以选择启用: ```yaml gateway: platforms: telegram: extra: - rich_messages: false + rich_messages: true ``` -富消息默认启用(`rich_messages: true`)。这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。 +这个设置用于客户端渲染兼容性;当 Telegram 拒绝富消息 API 调用时,Hermes 已经会自动回退。如果你只是想在保持富消息启用的同时恢复旧版「始终使用代码块」表格行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。 **链接预览。** Telegram 会为机器人消息中的 URL 自动生成链接预览。如果你希望抑制这些预览(长 `/tools` 输出、提及十个链接的 Agent 回复等):