diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index ccb6936dd74..3e7c729c0b9 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -508,16 +508,13 @@ PLATFORM_HINTS = { ), "telegram": ( "You are on a text messaging communication platform, Telegram. " - "Standard Markdown is automatically converted to Telegram formatting. " + "Standard markdown is automatically converted to Telegram format. " "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " "`inline code`, ```code blocks```, [links](url), and ## headers. " - "Telegram now supports rich Markdown, so when it improves clarity you " - "may use headings, tables (pipe `| col | col |` syntax), task lists " - "(`- [ ]` / `- [x]`), nested blockquotes, collapsible details, " - "footnotes/references, math/formulas (`$...$`, `$$...$$`), underline, " - "subscript/superscript, marked (highlighted) text, and anchors. Prefer " - "real Markdown tables and task lists over hand-built bullet substitutes " - "when presenting structured data. " + "Telegram has NO table syntax — prefer bullet lists or labeled " + "key: value pairs over pipe tables (any tables you do emit are " + "auto-rewritten into row-group bullets, which you can produce " + "directly for cleaner output). " "You can send media files natively: to deliver a file to the user, " "include MEDIA:/absolute/path/to/file in your response. Images " "(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice " diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 9daa313c23e..a741970ec51 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -721,8 +721,9 @@ platform_toolsets: # disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages # # Bot API 10.1 Rich Messages: final replies send raw markdown via # # sendRichMessage so tables, task lists, collapsible details, math, etc. -# # render natively (with automatic MarkdownV2 fallback). Default true. -# rich_messages: true # Set false to force the legacy MarkdownV2 path +# # render natively (with automatic MarkdownV2 fallback). Opt-in while +# # the new endpoint is validated; default false. +# rich_messages: false # Set true to enable native rich rendering # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 061d6b66d62..3cf24196678 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -419,10 +419,11 @@ class TelegramAdapter(BasePlatformAdapter): # Bot API 10.1 Rich Messages: opportunistically send final replies via # sendRichMessage with the raw agent markdown so tables/task lists/etc. # render natively. Opt-out via platforms.telegram.extra.rich_messages. - self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True) - # Latched off after a capability failure on sendRichMessageDraft (e.g. - # older python-telegram-bot without the endpoint) so streaming drafts - # stop re-attempting rich and use the legacy plain-text draft instead. + 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. + self._rich_send_disabled: bool = False self._rich_draft_disabled: bool = False # Buffer rapid/album photo updates so Telegram image bursts are handled # as a single MessageEvent instead of self-interrupting multiple turns. @@ -948,10 +949,12 @@ class TelegramAdapter(BasePlatformAdapter): return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None)) def _should_attempt_rich(self, content: str) -> bool: - # getattr default: tests build adapters via object.__new__() (no - # __init__), so ``_rich_messages_enabled`` may be unset — default ON. + # getattr defaults: tests build adapters via object.__new__() (no + # __init__), so the flags may be unset — default rich OFF (the + # feature is opt-in via platforms.telegram.extra.rich_messages). return bool( - getattr(self, "_rich_messages_enabled", True) + getattr(self, "_rich_messages_enabled", False) + and not getattr(self, "_rich_send_disabled", False) and content and content.strip() and self._content_fits_rich_limits(content) @@ -971,16 +974,14 @@ class TelegramAdapter(BasePlatformAdapter): payload["skip_entity_detection"] = True return payload - def _is_rich_fallback_error(self, exc: Exception) -> bool: - """True ⇒ permanent/capability error ⇒ safe to fall back to legacy. + def _is_rich_capability_error(self, exc: Exception) -> bool: + """True ⇒ the rich endpoint itself is unavailable (old PTB/server). - Conservative on purpose: only clearly-permanent failures (BadRequest, - capability errors, unknown/unsupported endpoint) qualify. Everything - else is treated as transient — the rich request may have reached - Telegram, so we must NOT legacy-resend and risk a duplicate. + These latch rich off for the rest of the adapter's life — retrying is + pointless and would cost a failed roundtrip on every send. Per-message + rejections (BadRequest from a parser/limit issue) are NOT capability + errors: the next message may be fine. """ - if self._is_bad_request_error(exc): - return True if isinstance(exc, (AttributeError, TypeError, NotImplementedError)): return True if getattr(exc, "error_code", None) == 404: @@ -992,6 +993,18 @@ class TelegramAdapter(BasePlatformAdapter): return True return False + def _is_rich_fallback_error(self, exc: Exception) -> bool: + """True ⇒ permanent/capability error ⇒ safe to fall back to legacy. + + Conservative on purpose: only clearly-permanent failures (BadRequest, + capability errors, unknown/unsupported endpoint) qualify. Everything + else is treated as transient — the rich request may have reached + Telegram, so we must NOT legacy-resend and risk a duplicate. + """ + if self._is_bad_request_error(exc): + return True + return self._is_rich_capability_error(exc) + def _compute_single_send_routing( self, chat_id: str, @@ -1064,9 +1077,11 @@ class TelegramAdapter(BasePlatformAdapter): payload.update({k: v for k, v in thread_kwargs.items() if v is not None}) payload.update(self._notification_kwargs(metadata)) if reply_to_id is not None: - # Scalar alias — safer to serialize through api_kwargs than the - # nested reply_parameters object on the raw endpoint. - payload["reply_to_message_id"] = reply_to_id + # Spec: sendRichMessage takes reply_parameters (ReplyParameters + # object), NOT the legacy reply_to_message_id scalar. Unknown + # params are silently ignored by the Bot API, so the scalar would + # quietly drop the reply anchor instead of erroring. + payload["reply_parameters"] = {"message_id": reply_to_id} try: msg = await self._bot.do_api_request( @@ -1074,6 +1089,10 @@ class TelegramAdapter(BasePlatformAdapter): ) except Exception as exc: if self._is_rich_fallback_error(exc): + if self._is_rich_capability_error(exc): + # Endpoint missing (old PTB/server) — latch rich off so + # every later send doesn't pay a doomed extra roundtrip. + self._rich_send_disabled = True logger.debug( "[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2", self.name, exc, @@ -1113,7 +1132,8 @@ 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 and content.strip() @@ -1148,7 +1168,7 @@ class TelegramAdapter(BasePlatformAdapter): ok = await self._bot.do_api_request("sendRichMessageDraft", api_kwargs=payload) return bool(ok) except Exception as exc: - if self._is_rich_fallback_error(exc): + if self._is_rich_capability_error(exc): self._rich_draft_disabled = True logger.debug( "[%s] sendRichMessageDraft unsupported (%s) — using legacy drafts", diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a1c5f4452bb..09acb74ce61 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -877,20 +877,6 @@ class TestPromptBuilderConstants: # check that this test is calibrated correctly). assert "include MEDIA:" in PLATFORM_HINTS["telegram"] - def test_telegram_hint_encourages_rich_markdown(self): - # Regression: Telegram now supports Bot API 10.1 Rich Messages, so the - # hint must encourage tables / task lists / rich Markdown and must no - # longer forbid tables. The adapter sends final replies via - # sendRichMessage with raw markdown (see test_telegram_rich_messages). - hint = PLATFORM_HINTS["telegram"] - lowered = hint.lower() - assert "Telegram has NO table syntax" not in hint - assert "table" in lowered - assert "task list" in lowered - assert "rich markdown" in lowered - # Local media delivery guidance must remain intact. - assert "include MEDIA:" in hint - def test_platform_hints_mattermost(self): hint = PLATFORM_HINTS["mattermost"] assert "Mattermost" in hint diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py index c697fceedea..8bb0b1702ff 100644 --- a/tests/gateway/test_telegram_rich_messages.py +++ b/tests/gateway/test_telegram_rich_messages.py @@ -27,8 +27,17 @@ RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n- 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 {}) + """Build a TelegramAdapter with a mock bot wired for the rich path. + + Rich messages are opt-in (default off) while the Bot API 10.1 endpoint + is validated live, so tests that exercise the rich path enable it + explicitly here; opt-out tests pass their own ``extra``. + """ + config = PlatformConfig( + enabled=True, + token="fake-token", + extra={"rich_messages": True} if extra is None else extra, + ) adapter = TelegramAdapter(config) bot = MagicMock() # do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True, @@ -133,6 +142,44 @@ async def test_unknown_endpoint_error_falls_back_to_legacy(): adapter._bot.send_message.assert_awaited() +@pytest.mark.asyncio +async def test_capability_error_latches_rich_send_off(): + """Endpoint-missing errors latch rich off so later sends skip the + doomed extra roundtrip entirely.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock(side_effect=RuntimeError("Method not found")) + + result = await adapter.send("12345", RICH_CONTENT) + assert result.success is True + assert adapter._rich_send_disabled is True + + # Second send skips rich entirely (no second do_api_request call). + adapter._bot.do_api_request.reset_mock() + adapter._bot.send_message.reset_mock() + result2 = await adapter.send("12345", RICH_CONTENT) + assert result2.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.send_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_per_message_bad_request_does_not_latch_off(): + """A parser/limit BadRequest is per-message — rich must stay enabled + for subsequent messages.""" + adapter = _make_adapter() + adapter._bot.do_api_request = AsyncMock(side_effect=BadRequest("can't parse rich message")) + + result = await adapter.send("12345", RICH_CONTENT) + assert result.success is True + assert adapter._rich_send_disabled is False + + # Next message re-attempts rich. + adapter._bot.do_api_request = AsyncMock(return_value=SimpleNamespace(message_id=124)) + result2 = await adapter.send("12345", RICH_CONTENT) + assert result2.success is True + adapter._bot.do_api_request.assert_awaited_once() + + @pytest.mark.asyncio @pytest.mark.parametrize("exc", [TimedOut("timed out"), NetworkError("connection reset")]) async def test_transient_rich_error_does_not_legacy_resend(exc): @@ -184,13 +231,17 @@ async def test_routing_direct_messages_topic_id_drops_message_thread_id(): @pytest.mark.asyncio -async def test_reply_to_propagates_as_scalar(): +async def test_reply_to_propagates_as_reply_parameters(): adapter = _make_adapter() await adapter.send("-100123", RICH_CONTENT, reply_to="999") api_kwargs = _rich_api_kwargs(adapter) - assert api_kwargs["reply_to_message_id"] == 999 + # Spec: sendRichMessage documents reply_parameters (ReplyParameters), not + # the legacy reply_to_message_id scalar — unknown params are silently + # ignored, which would quietly drop the reply anchor. + assert api_kwargs["reply_parameters"] == {"message_id": 999} + assert "reply_to_message_id" not in api_kwargs @pytest.mark.asyncio diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index e4597eab214..9b145fbbc01 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -900,14 +900,14 @@ 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. This is **on by default**; disable it (forcing the legacy MarkdownV2 path below) per platform: +**Rich Messages (Bot API 10.1).** When enabled, 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. This is **opt-in** (default off) while the new endpoint is validated; enable it per platform: ```yaml gateway: platforms: telegram: extra: - rich_messages: false + rich_messages: true ``` 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). 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 f8b6c26c7a8..399948015f3 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,14 +877,14 @@ gateway: ## 渲染:富消息、表格和链接预览 -**富消息(Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能**默认开启**;如需禁用(改用下方的旧版 MarkdownV2 路径),可按平台配置: +**富消息(Bot API 10.1)。** 启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能为**选择性启用**(默认关闭),在新端点经过验证期间需手动开启;可按平台配置: ```yaml gateway: platforms: telegram: extra: - rich_messages: false + rich_messages: true ``` 当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。