fix: rich messages follow-ups — reply_parameters, send latch, opt-in default

- Use reply_parameters per the sendRichMessage spec instead of the
  undocumented reply_to_message_id scalar (silently ignored -> reply
  anchor quietly dropped).
- Latch rich sends off after an endpoint-capability failure (old PTB /
  server without sendRichMessage) so every later reply doesn't pay a
  doomed extra roundtrip; per-message BadRequests do NOT latch.
- Default rich_messages to OFF (opt-in) while the day-old Bot API 10.1
  endpoint is validated live; revert the prompt-hint table guidance
  until the default flips on.
- Tests: reply_parameters shape, send-latch behavior, BadRequest
  non-latch; rich tests opt in explicitly via extra.
This commit is contained in:
Teknium 2026-06-12 03:13:15 -07:00
parent 05b9c84ca4
commit 652dd9c9f2
7 changed files with 107 additions and 52 deletions

View file

@ -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 "

View file

@ -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:):
#

View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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 `<details>`, 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 `<details>`, 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).

View file

@ -877,14 +877,14 @@ gateway:
## 渲染:富消息、表格和链接预览
**富消息Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能**默认开启**;如需禁用(改用下方的旧版 MarkdownV2 路径),可按平台配置:
**富消息Bot API 10.1)。** 启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能为**选择性启用**(默认关闭),在新端点经过验证期间需手动开启;可按平台配置:
```yaml
gateway:
platforms:
telegram:
extra:
rich_messages: false
rich_messages: true
```
当内容超过 32,768 字节的富文本上限时富消息路径会自动跳过Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。