mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-24 10:52:21 +00:00
Rich messages are not ready for primetime: current Telegram clients can render Bot API 10.1 rich messages as blank/unsupported bubbles and make them hard to copy as plain text, which is worse than the legacy MarkdownV2 path for command snippets and mobile handoffs. Default the rich_messages toggle to False so replies stay on the copyable legacy path; users opt in per bot via platforms.telegram.extra.rich_messages: true. Updates adapter, gateway config default, example config, English + zh-Hans docs, and the default/opt-in tests.
1052 lines
38 KiB
Python
1052 lines
38 KiB
Python
"""Tests for Bot API 10.1 Rich Messages (sendRichMessage) on Telegram.
|
|
|
|
Final / new-message replies opportunistically use ``sendRichMessage`` with the
|
|
RAW agent markdown so tables, task lists, etc. render natively. The legacy
|
|
MarkdownV2 ``send_message`` path stays as the fallback for unsupported /
|
|
oversized content and for transports that lack the endpoint.
|
|
|
|
The ``telegram`` package is mocked by ``tests/gateway/conftest.py``
|
|
(:func:`_ensure_telegram_mock`), so these tests construct a real
|
|
``TelegramAdapter`` and wire a mock bot.
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig
|
|
from gateway.platforms.base import SendResult
|
|
from plugins.platforms.telegram.adapter import TelegramAdapter
|
|
from telegram.error import BadRequest, NetworkError, TimedOut
|
|
|
|
|
|
# Content exercising rich-only constructs: a heading, a real Markdown table,
|
|
# and a task list. Pipes / brackets must survive untouched into the payload.
|
|
RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n- [x] table renders"
|
|
CJK_RICH_CONTENT = "## 持仓\n\n| 项目 | 状态 |\n|---|---|\n| 早盘 | 正常 |"
|
|
ASTRAL_CJK_RICH_CONTENT = "## Rare Han\n\n| glyph | status |\n|---|---|\n| \U00030000 | ok |"
|
|
DANGEROUS_DETAILS_MATH = (
|
|
"<details><summary>Complex proof</summary>\n\n"
|
|
"$$\\sum_{i=1}^{n} i = \\frac{n(n+1)}{2}$$\n\n"
|
|
"And inline \\(\\alpha + \\beta\\)\n"
|
|
"</details>"
|
|
)
|
|
|
|
# PTB 22.6's real unknown-endpoint errors: do_api_request can raise
|
|
# EndPointNotFound for Bot API 404s, and the request layer can wrap that same
|
|
# missing endpoint as InvalidToken. Use class names here so the tests don't
|
|
# depend on optional PTB internals.
|
|
EndPointNotFound = type("EndPointNotFound", (Exception,), {})
|
|
InvalidToken = type("InvalidToken", (Exception,), {})
|
|
PTB_ENDPOINT_NOT_FOUND = EndPointNotFound(
|
|
"Endpoint 'sendRichMessage' not found in Bot API"
|
|
)
|
|
PTB_INVALID_TOKEN_404 = InvalidToken(
|
|
"Either the bot token was rejected by Telegram or the endpoint "
|
|
"'sendRichMessage' does not exist."
|
|
)
|
|
|
|
|
|
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={"rich_messages": True, **(extra or {})},
|
|
)
|
|
adapter = TelegramAdapter(config)
|
|
bot = MagicMock()
|
|
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
|
|
# so _bot_supports_rich() is satisfied (real Bot.do_api_request is async too).
|
|
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() # 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
|
|
|
|
|
|
def _rich_api_kwargs(adapter):
|
|
"""Return the api_kwargs dict from the single sendRichMessage call."""
|
|
call = adapter._bot.do_api_request.call_args
|
|
assert call.args[0] == "sendRichMessage"
|
|
return call.kwargs["api_kwargs"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected_id"),
|
|
[
|
|
(SimpleNamespace(message_id=123), "123"),
|
|
({"message_id": 123}, "123"),
|
|
({"result": {"message_id": 123}}, "123"),
|
|
({"result": None}, None),
|
|
],
|
|
)
|
|
async def test_rich_result_shapes_extract_message_id(raw, expected_id):
|
|
"""The raw Bot API path may return either a PTB object or a raw dict."""
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(return_value=raw)
|
|
|
|
result = await adapter.send("12345", RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == expected_id
|
|
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_happy_path_sends_raw_markdown():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("12345", RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "123"
|
|
adapter._bot.do_api_request.assert_awaited_once()
|
|
api_kwargs = _rich_api_kwargs(adapter)
|
|
# Raw markdown — NOT MarkdownV2-escaped. Table pipes still present.
|
|
assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
|
|
assert "| Case | Status |" in api_kwargs["rich_message"]["markdown"]
|
|
assert "- [x] table renders" in api_kwargs["rich_message"]["markdown"]
|
|
# Legacy path must not run on rich success.
|
|
adapter._bot.send_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_details_with_math_skips_rich_send_to_avoid_tdesktop_crash():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("12345", DANGEROUS_DETAILS_MATH)
|
|
|
|
assert result.success is True
|
|
bot = adapter._bot
|
|
assert bot is not None
|
|
bot.do_api_request.assert_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_details_without_math_still_uses_rich_send():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send(
|
|
"12345",
|
|
"<details><summary>Notes</summary>\nNo equations here.\n</details>",
|
|
)
|
|
|
|
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_math_outside_details_still_uses_rich_send():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("12345", "Outside details: $$x^2 + y^2$$")
|
|
|
|
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_cjk_rich_content_skips_rich_send_to_avoid_tdesktop_garble():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("12345", CJK_RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.send_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_astral_cjk_rich_content_skips_rich_send_to_avoid_tdesktop_garble():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send("12345", ASTRAL_CJK_RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.send_message.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_messages_opt_out_uses_legacy_send_path():
|
|
adapter = _make_adapter(extra={"rich_messages": False})
|
|
|
|
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_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_messages_opt_out_accepts_string_false():
|
|
adapter = _make_adapter(extra={"rich_messages": "false"})
|
|
|
|
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_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_messages_default_is_legacy_copyable_path():
|
|
"""Rich messages stay opt-in because current Telegram clients can make
|
|
Bot API rich messages hard to copy as plain text. Rich-eligible content
|
|
defaults to the legacy MarkdownV2 path unless the user opts in."""
|
|
config = PlatformConfig(enabled=True, token="fake-token")
|
|
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 = adapter._bot
|
|
assert bot is not None
|
|
bot.do_api_request.assert_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_messages_can_be_opted_in():
|
|
"""Setting platforms.telegram.extra.rich_messages: true enables native
|
|
Bot API rich rendering for tables/task lists/details/math."""
|
|
config = PlatformConfig(
|
|
enabled=True, token="fake-token", extra={"rich_messages": True}
|
|
)
|
|
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 = 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
|
|
bot.do_api_request.assert_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_expect_edits_metadata_keeps_preview_on_legacy_path():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.send(
|
|
"12345",
|
|
RICH_CONTENT,
|
|
metadata={"expect_edits": True},
|
|
)
|
|
|
|
assert result.success is True
|
|
# Streaming preview sends will be edited later, so they must not be born as
|
|
# rich messages until Hermes wires rich_message edits directly.
|
|
bot = adapter._bot
|
|
assert bot is not None
|
|
bot.do_api_request.assert_not_called()
|
|
bot.send_message.assert_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_content_skips_rich_and_chunks():
|
|
adapter = _make_adapter()
|
|
# > 32,768 characters -> rich pre-check fails, legacy chunking takes over.
|
|
oversized = "a" * 40000
|
|
assert len(oversized) > TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
|
|
|
result = await adapter.send("12345", oversized)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
# Oversized content is split into multiple legacy chunks.
|
|
assert adapter._bot.send_message.await_count > 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_limit_is_characters_not_bytes():
|
|
"""Telegram's rich limit is UTF-8 characters, not encoded bytes."""
|
|
adapter = _make_adapter()
|
|
# Rich-eligible (table) so the content takes the rich path; the accented
|
|
# body is 20k chars / 40k UTF-8 bytes — over the byte count, under the
|
|
# character cap. CJK is intentionally avoided here because affected
|
|
# Telegram Desktop clients render CJK rich drafts incorrectly.
|
|
accented = "| a | b |\n|---|---|\n" + "é" * 20000
|
|
assert len(accented.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
|
|
assert len(accented) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
|
|
|
result = await adapter.send("12345", accented)
|
|
|
|
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
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
BadRequest("can't parse rich message"),
|
|
BadRequest("Method not found"),
|
|
],
|
|
)
|
|
async def test_permanent_rich_error_falls_back_to_legacy(exc):
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=exc)
|
|
|
|
result = await adapter.send("12345", RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_awaited_once()
|
|
adapter._bot.send_message.assert_awaited() # legacy fallback ran
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_endpoint_error_falls_back_to_legacy():
|
|
"""A non-BadRequest 'Method not found' (old PTB/endpoint) degrades gracefully."""
|
|
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
|
|
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
|
|
@pytest.mark.parametrize("exc", [PTB_ENDPOINT_NOT_FOUND, PTB_INVALID_TOKEN_404])
|
|
async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=exc)
|
|
|
|
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_awaited()
|
|
assert adapter._rich_send_disabled is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_payload_preserves_link_preview_disable():
|
|
adapter = _make_adapter(extra={"disable_link_previews": True})
|
|
|
|
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)
|
|
assert api_kwargs["link_preview_options"] == {"is_disabled": True}
|
|
|
|
|
|
@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):
|
|
"""Transient transport errors must NOT trigger a legacy resend (duplicate risk)."""
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=exc)
|
|
|
|
result = await adapter.send("12345", RICH_CONTENT)
|
|
|
|
assert result.success is False
|
|
adapter._bot.do_api_request.assert_awaited_once()
|
|
adapter._bot.send_message.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_transient_timeout_is_not_retryable():
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=TimedOut("timed out"))
|
|
|
|
result = await adapter.send("12345", RICH_CONTENT)
|
|
|
|
# A plain timeout may have reached Telegram -> non-retryable (no auto-resend).
|
|
assert result.success is False
|
|
assert result.retryable is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_routing_thread_id_maps_to_message_thread_id():
|
|
adapter = _make_adapter()
|
|
|
|
await adapter.send("-100123", RICH_CONTENT, metadata={"thread_id": "5"})
|
|
|
|
api_kwargs = _rich_api_kwargs(adapter)
|
|
assert api_kwargs["message_thread_id"] == 5
|
|
assert "direct_messages_topic_id" not in api_kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_routing_direct_messages_topic_id_drops_message_thread_id():
|
|
adapter = _make_adapter()
|
|
|
|
await adapter.send("-100123", RICH_CONTENT, metadata={"direct_messages_topic_id": "20189"})
|
|
|
|
api_kwargs = _rich_api_kwargs(adapter)
|
|
assert api_kwargs["direct_messages_topic_id"] == 20189
|
|
# _thread_kwargs_for_send pairs the topic id with message_thread_id=None;
|
|
# the rich payload must drop the None key, not send a stray field.
|
|
assert "message_thread_id" not in api_kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
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)
|
|
# 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
|
|
async def test_notification_silent_by_default():
|
|
adapter = _make_adapter()
|
|
|
|
await adapter.send("-100123", RICH_CONTENT)
|
|
|
|
api_kwargs = _rich_api_kwargs(adapter)
|
|
assert api_kwargs["disable_notification"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notification_opt_in_drops_disable_flag():
|
|
adapter = _make_adapter()
|
|
|
|
await adapter.send("-100123", RICH_CONTENT, metadata={"notify": True})
|
|
|
|
api_kwargs = _rich_api_kwargs(adapter)
|
|
assert "disable_notification" not in api_kwargs
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_gate_tolerates_minimal_bot_without_raw_endpoint():
|
|
"""A bot without an async do_api_request falls through to the legacy path."""
|
|
adapter = _make_adapter()
|
|
adapter._bot = SimpleNamespace(
|
|
send_message=AsyncMock(return_value=SimpleNamespace(message_id=42)),
|
|
send_chat_action=AsyncMock(),
|
|
)
|
|
|
|
result = await adapter.send("12345", "hello world")
|
|
|
|
assert result.success is True
|
|
assert result.message_id == "42"
|
|
|
|
|
|
# ── Streaming drafts: sendRichMessageDraft ─────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_details_with_math_skips_rich_draft_to_avoid_tdesktop_crash():
|
|
adapter = _make_adapter()
|
|
bot = adapter._bot
|
|
assert bot is not None
|
|
bot.do_api_request = AsyncMock(return_value=True)
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=DANGEROUS_DETAILS_MATH)
|
|
|
|
assert result.success is True
|
|
bot.do_api_request.assert_not_called()
|
|
bot.send_message_draft.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_draft_happy_path_sends_raw_markdown():
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(return_value=True)
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_awaited_once()
|
|
call = adapter._bot.do_api_request.call_args
|
|
assert call.args[0] == "sendRichMessageDraft"
|
|
api_kwargs = call.kwargs["api_kwargs"]
|
|
assert api_kwargs["draft_id"] == 7
|
|
assert api_kwargs["rich_message"]["markdown"] == RICH_CONTENT
|
|
# Legacy plain-text draft must not run when rich draft succeeds.
|
|
adapter._bot.send_message_draft.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cjk_rich_content_skips_rich_draft_to_avoid_tdesktop_garble():
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(return_value=True)
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=CJK_RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.send_message_draft.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_draft_capability_failure_falls_back_and_latches_off():
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=BadRequest("Method not found"))
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
|
|
|
assert result.success is True # legacy plain-text draft delivered the frame
|
|
adapter._bot.send_message_draft.assert_awaited_once()
|
|
assert adapter._rich_draft_disabled is True
|
|
|
|
# A subsequent frame skips the rich attempt entirely (latched off).
|
|
adapter._bot.do_api_request.reset_mock()
|
|
adapter._bot.send_message_draft.reset_mock()
|
|
result2 = await adapter.send_draft("12345", draft_id=8, content=RICH_CONTENT)
|
|
assert result2.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.send_message_draft.assert_awaited_once()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_draft_transient_failure_does_not_latch_off():
|
|
adapter = _make_adapter()
|
|
adapter._bot.do_api_request = AsyncMock(side_effect=TimedOut("timed out"))
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
|
|
|
assert result.success is True # legacy draft carried this frame
|
|
adapter._bot.send_message_draft.assert_awaited_once()
|
|
# Transient errors must NOT permanently disable rich drafts.
|
|
assert adapter._rich_draft_disabled is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_draft_oversized_uses_legacy():
|
|
adapter = _make_adapter()
|
|
oversized = "a" * 40000
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=oversized)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.send_message_draft.assert_awaited_once()
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# prefers_fresh_final_streaming: Telegram keeps streamed finals on the edit
|
|
# path, even when rich messages are enabled, so users do not briefly see two
|
|
# copies of the answer while the preview cleanup delete races the fresh send.
|
|
# ----------------------------------------------------------------------
|
|
def test_prefers_fresh_final_streaming_stays_disabled_when_rich_enabled():
|
|
adapter = _make_adapter()
|
|
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is False
|
|
|
|
|
|
def test_prefers_fresh_final_streaming_honors_rich_opt_out():
|
|
adapter = _make_adapter(extra={"rich_messages": False})
|
|
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is False
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# streaming_overflow_limit: with rich on, the stream consumer may accumulate up
|
|
# to the 32,768-char rich cap before splitting, so a reply that fits one
|
|
# sendRichMessage / sendRichMessageDraft isn't fragmented at the 4,096 limit.
|
|
# ----------------------------------------------------------------------
|
|
def test_streaming_overflow_limit_is_rich_cap_when_enabled():
|
|
adapter = _make_adapter()
|
|
assert adapter.streaming_overflow_limit() == TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
|
|
|
|
|
def test_streaming_overflow_limit_none_when_rich_opted_out():
|
|
adapter = _make_adapter(extra={"rich_messages": False})
|
|
assert adapter.streaming_overflow_limit() is None
|
|
|
|
|
|
def test_streaming_overflow_limit_none_when_rich_latched_off():
|
|
adapter = _make_adapter()
|
|
adapter._rich_send_disabled = True
|
|
assert adapter.streaming_overflow_limit() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_draft_opt_out_uses_legacy():
|
|
adapter = _make_adapter(extra={"rich_messages": False})
|
|
|
|
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
|
|
|
assert result.success is True
|
|
bot = adapter._bot
|
|
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_cjk_rich_content_stays_legacy_to_avoid_tdesktop_garble():
|
|
adapter = _make_adapter()
|
|
|
|
result = await adapter.edit_message(
|
|
"12345", "555", CJK_RICH_CONTENT, finalize=True,
|
|
)
|
|
|
|
assert result.success is True
|
|
adapter._bot.do_api_request.assert_not_called()
|
|
adapter._bot.edit_message_text.assert_awaited_once()
|
|
|
|
|
|
@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()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Rich-reply recovery (#47375): Telegram does not echo a sendRichMessage's
|
|
# content in reply_to_message (.text/.caption empty, .api_kwargs None), so we
|
|
# record message_id -> text at send time and recover it on inbound reply.
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _reply_message(reply_to_id, *, reply_text=None, reply_caption=None, quote_text=None):
|
|
"""Build a mock inbound reply Message for _build_message_event."""
|
|
replied = SimpleNamespace(
|
|
message_id=int(reply_to_id),
|
|
text=reply_text,
|
|
caption=reply_caption,
|
|
)
|
|
quote = SimpleNamespace(text=quote_text) if quote_text is not None else None
|
|
return SimpleNamespace(
|
|
message_id=999,
|
|
chat=SimpleNamespace(id=12345, type="private", title=None, full_name="U"),
|
|
from_user=SimpleNamespace(
|
|
id=42, username="u", first_name="U", last_name=None,
|
|
full_name="U", is_bot=False,
|
|
),
|
|
text="what did this mean?",
|
|
caption=None,
|
|
reply_to_message=replied,
|
|
quote=quote,
|
|
message_thread_id=None,
|
|
is_topic_message=False,
|
|
entities=[],
|
|
date=None,
|
|
)
|
|
|
|
|
|
def _reply_message_with_rich_blocks(
|
|
reply_to_id,
|
|
*,
|
|
blocks,
|
|
quote_text=None,
|
|
api_kwargs_factory=dict,
|
|
):
|
|
"""Build a reply whose echoed content lives only in api_kwargs.rich_message."""
|
|
replied = SimpleNamespace(
|
|
message_id=int(reply_to_id),
|
|
text=None,
|
|
caption=None,
|
|
api_kwargs=api_kwargs_factory({"rich_message": {"blocks": blocks}}),
|
|
)
|
|
quote = SimpleNamespace(text=quote_text) if quote_text is not None else None
|
|
return SimpleNamespace(
|
|
message_id=999,
|
|
chat=SimpleNamespace(id=12345, type="private", title=None, full_name="U"),
|
|
from_user=SimpleNamespace(
|
|
id=42, username="u", first_name="U", last_name=None,
|
|
full_name="U", is_bot=False,
|
|
),
|
|
text="what did this mean?",
|
|
caption=None,
|
|
reply_to_message=replied,
|
|
quote=quote,
|
|
message_thread_id=None,
|
|
is_topic_message=False,
|
|
entities=[],
|
|
date=None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_records_and_recovers_text(monkeypatch, tmp_path):
|
|
"""A reply to a rich-sent message resolves the original text via the index."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
from gateway import rich_sent_store
|
|
|
|
adapter = _make_adapter()
|
|
|
|
# _try_send_rich records (chat_id, message_id) -> content on a successful
|
|
# rich send. Drive that path directly so the test doesn't depend on send()
|
|
# gating heuristics (length, content shape) choosing the rich path.
|
|
adapter._bot.do_api_request = AsyncMock(
|
|
return_value=SimpleNamespace(message_id=678)
|
|
)
|
|
send_result = await adapter._try_send_rich(
|
|
"12345", "Your morning briefing: CI is green.", None, None,
|
|
)
|
|
assert send_result is not None and send_result.success is True
|
|
assert send_result.message_id == "678"
|
|
assert rich_sent_store.lookup("12345", "678") == "Your morning briefing: CI is green."
|
|
|
|
# Inbound reply carries NO text/caption (the rich-message blind spot).
|
|
event = adapter._build_message_event(
|
|
_reply_message("678"), MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_message_id == "678"
|
|
assert event.reply_to_text == "Your morning briefing: CI is green."
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_lookup_miss_leaves_text_none(monkeypatch, tmp_path):
|
|
"""No recorded entry -> reply_to_text stays None, no crash."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message("404"), MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_message_id == "404"
|
|
assert event.reply_to_text is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_native_quote_wins_over_lookup(monkeypatch, tmp_path):
|
|
"""A native partial quote takes precedence over the send-time index."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
from gateway import rich_sent_store
|
|
|
|
rich_sent_store.record("12345", "678", "full recorded body")
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message("678", quote_text="just this part"), MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_text == "just this part"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_caption_wins_over_lookup(monkeypatch, tmp_path):
|
|
"""When Telegram DOES echo a caption, it wins over the index fallback."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
from gateway import rich_sent_store
|
|
|
|
rich_sent_store.record("12345", "678", "recorded body")
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message("678", reply_caption="echoed caption"), MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_text == "echoed caption"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_native_blocks_fill_reply_text_without_index(monkeypatch, tmp_path):
|
|
"""Echoed rich_message blocks should recover reply text natively."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message_with_rich_blocks(
|
|
"678",
|
|
blocks=[
|
|
{"type": "paragraph", "text": ["Hello ", {"type": "bold", "text": "world"}]},
|
|
{"type": "pre", "text": "Line 2"},
|
|
],
|
|
),
|
|
MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_text == "Hello world\nLine 2"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_native_blocks_win_over_index(monkeypatch, tmp_path):
|
|
"""Native rich echo should beat the local send-time index fallback."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
from gateway import rich_sent_store
|
|
|
|
rich_sent_store.record("12345", "678", "recorded body")
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message_with_rich_blocks(
|
|
"678",
|
|
blocks=[{"type": "paragraph", "text": ["Echoed ", {"type": "italic", "text": "body"}]}],
|
|
),
|
|
MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_text == "Echoed body"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_rich_reply_native_blocks_support_mappingproxy_like_api_kwargs(monkeypatch, tmp_path):
|
|
"""Duck-type api_kwargs via .get() so mappingproxy-like objects also work."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway.platforms.base import MessageType
|
|
|
|
class MappingProxyLike(dict):
|
|
pass
|
|
|
|
adapter = _make_adapter()
|
|
event = adapter._build_message_event(
|
|
_reply_message_with_rich_blocks(
|
|
"678",
|
|
blocks=[
|
|
{"type": "heading", "text": "Status", "size": 2},
|
|
{"type": "list", "items": [{"label": "-", "blocks": [{"type": "paragraph", "text": ["done"]}]}]},
|
|
],
|
|
api_kwargs_factory=MappingProxyLike,
|
|
),
|
|
MessageType.TEXT,
|
|
)
|
|
assert event.reply_to_text == "Status\n- done"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_try_edit_rich_records_streamed_final_for_reply_recovery(monkeypatch, tmp_path):
|
|
"""A streamed final finalized via editMessageText must be indexed too.
|
|
|
|
The native rich echo covers most replies, but messages that predate the
|
|
bot's first rich send have no echo — so editMessageText must mirror the
|
|
fresh-send index the same way _try_send_rich does.
|
|
"""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
from gateway import rich_sent_store
|
|
|
|
adapter = _make_adapter()
|
|
result = await adapter._try_edit_rich("12345", "5724", "Готово. Основной бот живой.")
|
|
assert result is not None and result.success
|
|
assert rich_sent_store.lookup("12345", "5724") == "Готово. Основной бот живой."
|