From 05b9c84ca4b154011352a5c8ee463801621b81be Mon Sep 17 00:00:00 2001 From: ITheEqualizer Date: Fri, 12 Jun 2026 12:01:03 +0330 Subject: [PATCH] Add Telegram Bot API 10.1 rich message support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce opportunistic support for Telegram Bot API 10.1 rich messages by sending raw agent Markdown via sendRichMessage and streaming previews via sendRichMessageDraft. Implements a rich-path fast‑path in gateway/platforms/telegram.py (RICH_MESSAGE_MAX_BYTES=32768, feature gate platforms.telegram.extra.rich_messages, bot capability checks, routing/thread handling, and conservative fallback rules: permanent/capability errors fall back to the legacy MarkdownV2 path, transient/network errors are surfaced without legacy-resend). Also add a latch for draft capability failures (_rich_draft_disabled) and preserve legacy chunking and draft behavior when needed. Update agent prompt hints (telegram encourages rich Markdown/tables), add CLI config example option, update English and Chinese docs to describe rich messages and fallbacks, and add/adjust tests for rich send and draft behavior. --- agent/prompt_builder.py | 13 +- cli-config.yaml.example | 4 + gateway/platforms/telegram.py | 302 ++++++++++++++++- tests/agent/test_prompt_builder.py | 14 + tests/gateway/test_telegram_rich_messages.py | 309 ++++++++++++++++++ website/docs/user-guide/messaging/telegram.md | 20 +- .../current/user-guide/messaging/telegram.md | 20 +- 7 files changed, 662 insertions(+), 20 deletions(-) create mode 100644 tests/gateway/test_telegram_rich_messages.py diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3e7c729c0b9..ccb6936dd74 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -508,13 +508,16 @@ PLATFORM_HINTS = { ), "telegram": ( "You are on a text messaging communication platform, Telegram. " - "Standard markdown is automatically converted to Telegram format. " + "Standard Markdown is automatically converted to Telegram formatting. " "Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, " "`inline code`, ```code blocks```, [links](url), and ## headers. " - "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). " + "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. " "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 " diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 8ce9ad8e19a..9daa313c23e 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -719,6 +719,10 @@ platform_toolsets: # # allowed_chats: ["-1001234567890"] # extra: # 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 # # Discord-specific settings (config.yaml top-level, not under platforms:): # diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index eec156bbae9..061d6b66d62 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -9,6 +9,7 @@ Uses python-telegram-bot library for: import asyncio import dataclasses +import inspect import json import logging import os @@ -347,6 +348,9 @@ class TelegramAdapter(BasePlatformAdapter): # Telegram message limits MAX_MESSAGE_LENGTH = 4096 supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks + # Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768 + # UTF-8 bytes. Content above this is sent via the legacy chunking path. + RICH_MESSAGE_MAX_BYTES = 32768 # Threshold for detecting Telegram client-side message splits. # When a chunk is near this limit, a continuation is almost certain. _SPLIT_THRESHOLD = 4000 @@ -412,6 +416,14 @@ 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: 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_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. self._media_batch_delay_seconds = float(os.getenv("HERMES_TELEGRAM_MEDIA_BATCH_DELAY_SECONDS", "0.8")) @@ -902,6 +914,253 @@ class TelegramAdapter(BasePlatformAdapter): return {"link_preview_options": LinkPreviewOptions(is_disabled=True)} return {"disable_web_page_preview": True} + # ------------------------------------------------------------------ + # Bot API 10.1 Rich Messages (sendRichMessage) + # + # Final / new-message replies opportunistically use sendRichMessage with + # the RAW agent markdown so richer constructs (tables, task lists, + # collapsible details, math, ...) render natively. The legacy MarkdownV2 + # send() path stays as the fallback for unsupported/oversized content and + # older PTB/clients. Streaming edits/drafts are intentionally untouched — + # Telegram exposes no rich-edit method. + # ------------------------------------------------------------------ + def _content_fits_rich_limits(self, content: str) -> bool: + """Cheap pre-check for the one hard rich limit we can count locally. + + Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API + rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are + not pre-counted; if exceeded Telegram returns a BadRequest, which + :meth:`_is_rich_fallback_error` classifies as permanent so the send + degrades to the legacy chunking path. + """ + return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES + + def _bot_supports_rich(self) -> bool: + """True when the bound bot can issue raw ``sendRichMessage`` calls. + + Gates on ``do_api_request`` being an *async* callable. The real + ``telegram.Bot.do_api_request`` is a coroutine function; test doubles + that opt into rich set it to an ``AsyncMock`` (also a coroutine + function). Plain ``MagicMock`` bots expose a *sync* auto-child and + ``SimpleNamespace`` bots lack the attribute entirely — both resolve to + ``False`` here, so the legacy path is used unchanged. + """ + 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. + return bool( + getattr(self, "_rich_messages_enabled", True) + and content + and content.strip() + and self._content_fits_rich_limits(content) + and self._bot_supports_rich() + ) + + def _rich_message_payload( + self, content: str, *, skip_entity_detection: bool = False + ) -> Dict[str, Any]: + """Build the ``InputRichMessage`` object from RAW markdown. + + Never pass ``format_message(content)`` here — that converts to + MarkdownV2 and would escape/destroy rich syntax like table pipes. + """ + payload: Dict[str, Any] = {"markdown": content} + if skip_entity_detection: + 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. + + 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 + if isinstance(exc, (AttributeError, TypeError, NotImplementedError)): + return True + if getattr(exc, "error_code", None) == 404: + return True + s = str(exc).lower() + if ("method" in s and "not found" in s) or "no such method" in s: + return True + if "unsupported" in s or "not implemented" in s: + return True + return False + + def _compute_single_send_routing( + self, + chat_id: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + thread_id: Optional[str], + ) -> Optional[tuple]: + """Routing for a single (rich) send — mirrors send()'s index-0 block. + + Returns ``(reply_to_id, thread_kwargs)``, or ``None`` to signal "skip + rich, let the legacy path handle it" — used for the DM-topic fail-loud + case so the legacy path stays the single source of the refuse result. + """ + metadata_reply_to = self._metadata_reply_to_message_id(metadata) + private_dm_topic_send = self._is_private_dm_topic_send(chat_id, thread_id, metadata) + dm_topic_reply_to_off = ( + private_dm_topic_send + and self._reply_to_mode == "off" + and bool(metadata and metadata.get("telegram_dm_topic_reply_fallback")) + ) + reply_to_source = reply_to or ( + str(metadata_reply_to) + if private_dm_topic_send and metadata_reply_to is not None + else None + ) + if private_dm_topic_send: + should_thread = reply_to_source is not None and self._reply_to_mode != "off" + else: + should_thread = self._should_thread_reply(reply_to_source, 0) + reply_to_id = int(reply_to_source) if should_thread and reply_to_source else None + if private_dm_topic_send and reply_to_id is None and not dm_topic_reply_to_off: + # Refusing to send outside the requested DM topic — defer to the + # legacy path, which returns the canonical fail-loud SendResult. + return None + thread_kwargs = self._thread_kwargs_for_send( + chat_id, + thread_id, + metadata, + reply_to_message_id=reply_to_id, + reply_to_mode=self._reply_to_mode, + ) + return reply_to_id, thread_kwargs + + async def _try_send_rich( + self, + chat_id: str, + content: str, + reply_to: Optional[str], + metadata: Optional[Dict[str, Any]], + ) -> Optional[SendResult]: + """Attempt a single ``sendRichMessage`` send. + + Returns a :class:`SendResult` (success, or a transient failure that the + caller must NOT legacy-resend), or ``None`` to signal "fall back to the + legacy MarkdownV2 path" (permanent/capability error or DM-topic skip). + """ + thread_id = self._metadata_thread_id(metadata) + routing = self._compute_single_send_routing(chat_id, reply_to, metadata, thread_id) + if routing is None: + return None + reply_to_id, thread_kwargs = routing + + payload: Dict[str, Any] = { + "chat_id": int(chat_id), + "rich_message": self._rich_message_payload(content), + } + # Only forward non-None routing keys: when direct_messages_topic_id is + # present _thread_kwargs_for_send pairs it with message_thread_id=None, + # which must not be sent as a stray field on the raw endpoint. + 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 + + try: + msg = await self._bot.do_api_request( + "sendRichMessage", api_kwargs=payload, return_type=Message + ) + except Exception as exc: + if self._is_rich_fallback_error(exc): + logger.debug( + "[%s] sendRichMessage rejected (%s) — falling back to MarkdownV2", + self.name, exc, + ) + return None + # Transient / network / unknown: the request may have reached + # Telegram. Do NOT legacy-resend (duplicate risk); surface a + # failure with retry semantics mirroring the legacy send() except. + 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] sendRichMessage transient failure (no legacy resend): %s", + self.name, exc, + ) + return SendResult( + success=False, + error=str(exc), + retryable=(is_connect_timeout or not is_timeout), + ) + + message_id = None + if isinstance(msg, dict): + message_id = msg.get("message_id") + if message_id is None: + message_id = msg.get("result", {}).get("message_id") + else: + message_id = getattr(msg, "message_id", None) + return SendResult( + success=True, + message_id=str(message_id) if message_id is not None else None, + ) + + def _should_attempt_rich_draft(self, content: str) -> bool: + return bool( + getattr(self, "_rich_messages_enabled", True) + and not getattr(self, "_rich_draft_disabled", False) + and content + and content.strip() + and self._content_fits_rich_limits(content) + and self._bot_supports_rich() + ) + + async def _try_send_rich_draft( + self, + chat_id: str, + draft_id: int, + content: str, + metadata: Optional[Dict[str, Any]], + ) -> bool: + """Emit one ``sendRichMessageDraft`` preview frame; True on success. + + Draft frames are ephemeral and overwritten by the next frame / the + final ``sendRichMessage``, so a duplicate or lost rich draft is + harmless — any failure simply returns False and the caller renders the + legacy plain-text draft. A permanent/capability failure additionally + latches ``_rich_draft_disabled`` so later frames skip the rich attempt. + """ + payload: Dict[str, Any] = { + "chat_id": int(chat_id), + "draft_id": int(draft_id), + "rich_message": self._rich_message_payload(content), + } + thread_id = self._metadata_thread_id(metadata) + if thread_id is not None: + payload["message_thread_id"] = int(thread_id) + try: + 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): + self._rich_draft_disabled = True + logger.debug( + "[%s] sendRichMessageDraft unsupported (%s) — using legacy drafts", + self.name, exc, + ) + else: + logger.debug( + "[%s] sendRichMessageDraft transient failure (%s) — legacy draft this frame", + self.name, exc, + ) + return False + async def _drain_polling_connections(self) -> None: """Reset the httpx connection pool used for getUpdates polling. @@ -1869,6 +2128,22 @@ class TelegramAdapter(BasePlatformAdapter): return SendResult(success=True, message_id=None) try: + # Bot API 10.1 rich fast-path: send the raw agent markdown via + # sendRichMessage so tables/task lists/etc. render natively. Falls + # through to the legacy MarkdownV2 path on permanent/capability + # errors or DM-topic routing skips; returns directly on success or + # on a transient failure (which must NOT be legacy-resent). + if self._should_attempt_rich(content): + rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata) + if rich_result is not None: + if rich_result.success: + # Re-trigger typing like the legacy success path does. + try: + await self.send_typing(chat_id, metadata=metadata) + except Exception: + pass # Typing failures are non-fatal + return rich_result + # Format and split message if needed formatted = self.format_message(content) chunks = self.truncate_message( @@ -2550,17 +2825,30 @@ class TelegramAdapter(BasePlatformAdapter): content: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Stream a partial message via Telegram's native sendMessageDraft. + """Stream a partial message via Telegram's native draft API. - The Bot API animates the preview when the same ``draft_id`` is reused - across consecutive calls in the same chat. When the response - finishes, the caller sends the final text via the normal ``send`` - path; the draft preview clears naturally on the client (Telegram has - no Bot API to "promote" a draft to a real message — the final - ``sendMessage`` is what the user receives in their history). + Uses ``sendRichMessageDraft`` (Bot API 10.1) with the raw markdown when + rich messages are enabled and supported, otherwise the plain-text + ``sendMessageDraft``. The Bot API animates the preview when the same + ``draft_id`` is reused across consecutive calls in the same chat. When + the response finishes, the caller sends the final text via the normal + ``send`` path; the draft preview clears naturally on the client + (Telegram has no Bot API to "promote" a draft to a real message — the + final ``sendMessage``/``sendRichMessage`` is what the user receives in + their history). """ if not self._bot: return SendResult(success=False, error="not_connected") + + # Rich draft fast-path (Bot API 10.1 sendRichMessageDraft): render the + # streaming preview with the same raw markdown the final + # sendRichMessage will persist, so the animated draft matches the final + # message. Any failure degrades to the legacy plain-text draft below. + if self._should_attempt_rich_draft(content): + if await self._try_send_rich_draft(chat_id, draft_id, content, metadata): + # Drafts have no message_id; report success without one. + return SendResult(success=True, message_id=None) + if not hasattr(self._bot, "send_message_draft"): return SendResult(success=False, error="api_unavailable") diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 09acb74ce61..a1c5f4452bb 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -877,6 +877,20 @@ 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 diff --git a/tests/gateway/test_telegram_rich_messages.py b/tests/gateway/test_telegram_rich_messages.py new file mode 100644 index 00000000000..c697fceedea --- /dev/null +++ b/tests/gateway/test_telegram_rich_messages.py @@ -0,0 +1,309 @@ +"""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 gateway.platforms.telegram 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" + + +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 {}) + 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 + 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 +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_rich_opt_out_uses_legacy(): + adapter = _make_adapter(extra={"rich_messages": False}) + + result = await adapter.send("12345", RICH_CONTENT) + + assert result.success is True + adapter._bot.do_api_request.assert_not_called() + adapter._bot.send_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_rich_opt_out_accepts_string_false(): + adapter = _make_adapter(extra={"rich_messages": "false"}) + + await adapter.send("12345", RICH_CONTENT) + + adapter._bot.do_api_request.assert_not_called() + adapter._bot.send_message.assert_awaited() + + +@pytest.mark.asyncio +async def test_oversized_content_skips_rich_and_chunks(): + adapter = _make_adapter() + # > 32,768 UTF-8 bytes -> rich pre-check fails, legacy chunking takes over. + oversized = "a" * 40000 + assert len(oversized.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES + + 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 +@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 +@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_scalar(): + 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 + + +@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_missing_enabled_attr(): + """Adapters missing _rich_messages_enabled (object.__new__ in some tests) + must not raise — the gate reads it via getattr(default=True), and a bot + without an async do_api_request falls through to the legacy path.""" + adapter = _make_adapter() + del adapter._rich_messages_enabled # simulate object.__new__ construction + # SimpleNamespace bot has no do_api_request -> _bot_supports_rich() False. + 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_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_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_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 + adapter._bot.do_api_request.assert_not_called() + adapter._bot.send_message_draft.assert_awaited_once() + + +@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() diff --git a/website/docs/user-guide/messaging/telegram.md b/website/docs/user-guide/messaging/telegram.md index a2ac8cb584f..e4597eab214 100644 --- a/website/docs/user-guide/messaging/telegram.md +++ b/website/docs/user-guide/messaging/telegram.md @@ -898,14 +898,26 @@ gateway: **What if a draft frame fails?** Any failure (transient network error, server-side rejection, older python-telegram-bot install) flips that response back to the edit-based path for the rest of the stream. The next response gets a fresh attempt. -## Rendering: Tables and Link Previews +## Rendering: Rich Messages, Tables and Link Previews -Telegram's MarkdownV2 has no native table syntax — pipe tables render as backslash-escaped noise if passed through raw. Hermes normalizes markdown tables automatically: +**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 `
`, 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: + +```yaml +gateway: + platforms: + telegram: + extra: + rich_messages: false +``` + +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). + +**MarkdownV2 fallback.** When the rich path is disabled or unavailable, 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. A one-line prompt hint is added so the agent knows to prefer prose follow-ups over more tables on Telegram. +- **Larger or wider tables** fall back to a **fenced code block** with aligned columns so nothing collapses. -There's nothing to configure — the adapter picks the right fallback per message. If you want the legacy "always code-block" behavior, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`). +There's nothing to configure for the fallback — the adapter picks the right rendering per message. If you want the legacy "always code-block" behavior, disable table normalization by setting `telegram.pretty_tables: false` in `config.yaml` (default: `true`). **Link previews.** Telegram auto-generates link previews for URLs in bot messages. If you'd rather suppress those (long `/tools` output, agent reply that mentions ten links, etc.): diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md index a65393202e2..f8b6c26c7a8 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/messaging/telegram.md @@ -875,14 +875,26 @@ gateway: **如果草稿帧失败怎么办?** 任何失败(瞬时网络错误、服务器端拒绝、旧版 python-telegram-bot 安装)都会将该响应的剩余流切换回基于编辑的路径。下一个响应会重新尝试。 -## 渲染:表格和链接预览 +## 渲染:富消息、表格和链接预览 -Telegram 的 MarkdownV2 没有原生表格语法——如果直接传递管道表格,会渲染为反斜杠转义的噪音。Hermes 自动规范化 markdown 表格: +**富消息(Bot API 10.1)。** 最终回复通过 Telegram 原生的 [`sendRichMessage`](https://core.telegram.org/bots/api#sendrichmessage) 发送,使用 Agent 的**原始 markdown**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `
`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `sendRichMessageDraft`,因此动画草稿与最终的富消息保持一致。此功能**默认开启**;如需禁用(改用下方的旧版 MarkdownV2 路径),可按平台配置: + +```yaml +gateway: + platforms: + telegram: + extra: + rich_messages: false +``` + +当内容超过 32,768 字节的富文本上限时,富消息路径会自动跳过;Telegram 的任何拒绝(较旧 `python-telegram-bot` 不支持该端点、解析错误、块/列过多)都会**透明回退**到 MarkdownV2 路径——消息绝不会丢失。瞬时/网络错误**不会**被静默重发(不会产生重复的最终消息)。 + +**MarkdownV2 回退。** 当富消息路径被禁用或不可用时,Hermes 会将 markdown 转换为 MarkdownV2。由于 MarkdownV2 没有原生表格语法,管道表格会被规范化: - **小表格**被展平为**行组项目符号**——每行在列标题下变为可读的项目符号列表。适合 2-4 列和短单元格。 -- **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。还会添加一行 prompt 提示,让 Agent 知道在 Telegram 上优先使用散文而非更多表格。 +- **较大或较宽的表格**回退为带对齐列的**围栏代码块**,以防内容折叠。 -无需配置——适配器会为每条消息选择正确的回退方式。如果你想要旧版"始终使用代码块"行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。 +回退无需配置——适配器会为每条消息选择正确的渲染方式。如果你想要旧版"始终使用代码块"行为,可在 `config.yaml` 中设置 `telegram.pretty_tables: false` 禁用表格规范化(默认:`true`)。 **链接预览。** Telegram 会为机器人消息中的 URL 自动生成链接预览。如果你希望抑制这些预览(长 `/tools` 输出、提及十个链接的 Agent 回复等):