mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-20 10:11:58 +00:00
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:
parent
6b4073648e
commit
05b9c84ca4
7 changed files with 662 additions and 20 deletions
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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:):
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
309
tests/gateway/test_telegram_rich_messages.py
Normal file
309
tests/gateway/test_telegram_rich_messages.py
Normal 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()
|
||||
|
|
@ -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 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.):
|
||||
|
||||
|
|
|
|||
|
|
@ -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 回复等):
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue