Add Telegram Bot API 10.1 rich message support

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.
This commit is contained in:
ITheEqualizer 2026-06-12 12:01:03 +03:30 committed by Teknium
parent 6b4073648e
commit 05b9c84ca4
7 changed files with 662 additions and 20 deletions

View file

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

View file

@ -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:):
#

View file

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

View file

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

View file

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

View file

@ -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 `<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. 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 24 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.):

View file

@ -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**,因此表格、任务列表、标题、嵌套引用块、可折叠的 `<details>`、脚注/引用、数学公式、下划线、上下标、高亮文本和锚点都能原生渲染——无需客户端展平。在私聊中,实时流式预览也使用 `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 回复等):