fix(telegram): edit streamed previews in place as rich (Bot API 10.1) (#46890)
Streamed Telegram replies that finalize through editMessageText were converted to MarkdownV2, which has no table syntax and rewrites pipe tables into bullet lists — users saw a table while streaming that collapsed to a list at the last moment. Finalize now edits the existing preview IN PLACE via Bot API 10.1's editMessageText rich_message parameter when the content has constructs the legacy path degrades (tables, task lists, <details>, block math). No fresh send + delete, so no duplicate-preview flicker — the reason #46206 reverted the fresh-final re-send path. prefers_fresh_final_streaming stays False; the in-place edit replaces it. - _needs_rich_rendering(): rich reserved for table/task-list/details/math (adapted from #45995, @YonganZhang); plain replies stay on MarkdownV2. - _try_edit_rich(): editMessageText + rich_message via do_api_request, mirroring _try_send_rich's fallback/latch/transient contract. - edit_message finalize tries rich in place before the 4,096 overflow pre-flight (rich cap is 32,768), falling back to legacy on rejection. - rich_messages default flipped back to True (DEFAULT_CONFIG + adapter). - docs (en + zh-Hans) + cli-config example updated to default-on. Closes the root cause behind #45911 / #46009.
This commit is contained in:
+140
-21
@@ -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, <details>, 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 ``<details>`` 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)^<details\b|^</details>|^<summary\b|^</summary>", 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, <details>, 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:
|
||||
|
||||
Reference in New Issue
Block a user