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:
Teknium 2026-06-16 05:26:04 -07:00 committed by GitHub
parent 5b3fa26366
commit a6364bfa08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 336 additions and 36 deletions

View file

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

View file

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