mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
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:
parent
5b3fa26366
commit
a6364bfa08
7 changed files with 336 additions and 36 deletions
|
|
@ -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:):
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 `<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.
|
||||
**Rich Messages (Bot API 10.1).** Final replies that contain constructs the legacy MarkdownV2 path degrades — tables, task lists, collapsible `<details>`, 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`).
|
||||
|
|
|
|||
|
|
@ -877,23 +877,23 @@ gateway:
|
|||
|
||||
## 渲染:富消息、表格和链接预览
|
||||
|
||||
**富消息(Bot API 10.1)。** 选择启用后,最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。
|
||||
**富消息(Bot API 10.1)。** 最终回复中那些会被旧版 MarkdownV2 路径降级的结构——表格、任务列表、可折叠的 `<details>` 以及块级数学公式——会通过 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`)。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue