diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index d1afb6c46690..237792d09e00 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -726,7 +726,16 @@ def test_telegram_media_attaches_to_last_chunk(self): sent_calls = [] - async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False): + async def fake_send( + token, + chat_id, + message, + media_files=None, + thread_id=None, + disable_link_previews=False, + force_document=False, + rich_messages=False, + ): sent_calls.append(media_files or []) return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))} @@ -744,6 +753,42 @@ async def fake_send(token, chat_id, message, media_files=None, thread_id=None, d assert all(call == [] for call in sent_calls[:-1]) assert sent_calls[-1] == media + def test_telegram_rich_messages_config_string_is_coerced(self): + sent_flags = [] + + async def fake_send( + token, + chat_id, + message, + media_files=None, + thread_id=None, + disable_link_previews=False, + force_document=False, + rich_messages=False, + ): + sent_flags.append(rich_messages) + return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": "1"} + + with patch("tools.send_message_tool._send_telegram", fake_send): + asyncio.run( + _send_to_platform( + Platform.TELEGRAM, + SimpleNamespace(enabled=True, token="tok", extra={"rich_messages": "false"}), + "123", + "plain", + ) + ) + asyncio.run( + _send_to_platform( + Platform.TELEGRAM, + SimpleNamespace(enabled=True, token="tok", extra={"rich_messages": "true"}), + "123", + "plain", + ) + ) + + assert sent_flags == [False, True] + def test_matrix_media_uses_native_adapter_helper(self, tmp_path): doc_path = tmp_path / "test-send-message-matrix.pdf" doc_path.write_bytes(b"%PDF-1.4 test") @@ -904,6 +949,100 @@ def test_plain_text_uses_markdown_v2(self, monkeypatch): kwargs = bot.send_message.await_args.kwargs assert kwargs["parse_mode"] == "MarkdownV2" + def test_rich_messages_use_send_rich_message_with_raw_markdown(self, monkeypatch): + bot = self._make_bot() + bot.do_api_request = AsyncMock( + return_value={"message_id": 42, "rich_message": {"blocks": []}} + ) + _install_telegram_mock(monkeypatch, bot) + + result = asyncio.run( + _send_telegram( + "tok", + "123", + "## Heading\n\n| A | B |\n| - | - |\n| **x** | *y* |\n\n- [x] done", + rich_messages=True, + ) + ) + + assert result["success"] is True + assert result["message_id"] == "42" + bot.do_api_request.assert_awaited_once_with( + "sendRichMessage", + api_kwargs={ + "chat_id": 123, + "rich_message": { + "markdown": "## Heading\n\n| A | B |\n| - | - |\n| **x** | *y* |\n\n- [x] done", + }, + }, + ) + bot.send_message.assert_not_awaited() + + def test_rich_messages_rejected_falls_back_to_markdown_v2(self, monkeypatch): + bot = self._make_bot() + bot.do_api_request = AsyncMock( + side_effect=Exception("Bad Request: can't parse rich markdown") + ) + _install_telegram_mock(monkeypatch, bot) + + result = asyncio.run( + _send_telegram("tok", "123", "**fallback**", rich_messages=True) + ) + + assert result["success"] is True + bot.do_api_request.assert_awaited_once() + bot.send_message.assert_awaited_once() + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "MarkdownV2" + + def test_rich_messages_extract_nested_result_message_id(self, monkeypatch): + bot = self._make_bot() + bot.do_api_request = AsyncMock( + return_value={"ok": True, "result": {"message_id": 314}} + ) + _install_telegram_mock(monkeypatch, bot) + + result = asyncio.run( + _send_telegram("tok", "123", "## nested", rich_messages=True) + ) + + assert result["success"] is True + assert result["message_id"] == "314" + bot.send_message.assert_not_awaited() + + def test_rich_messages_disable_link_previews_uses_rich_preview_options(self, monkeypatch): + bot = self._make_bot() + bot.do_api_request = AsyncMock(return_value={"message_id": 43}) + _install_telegram_mock(monkeypatch, bot) + + asyncio.run( + _send_telegram( + "tok", + "123", + "https://example.com", + disable_link_previews=True, + rich_messages=True, + ) + ) + + api_kwargs = bot.do_api_request.await_args.kwargs["api_kwargs"] + assert api_kwargs["link_preview_options"] == {"is_disabled": True} + bot.send_message.assert_not_awaited() + + def test_rich_messages_transient_failure_does_not_legacy_resend(self, monkeypatch): + bot = self._make_bot() + bot.do_api_request = AsyncMock(side_effect=TimeoutError("network timeout")) + _install_telegram_mock(monkeypatch, bot) + + result = asyncio.run( + _send_telegram("tok", "123", "**no duplicate**", rich_messages=True) + ) + + assert "error" in result + assert "network timeout" in result["error"] + bot.do_api_request.assert_awaited_once() + bot.send_message.assert_not_awaited() + def test_disable_link_previews_sets_disable_web_page_preview(self, monkeypatch): bot = self._make_bot() _install_telegram_mock(monkeypatch, bot) diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index a37f9eb62a29..75fc872b9b11 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -6,6 +6,7 @@ """ import asyncio +import inspect import json import logging import os @@ -13,6 +14,7 @@ import ssl import time from email.utils import formatdate +from typing import Awaitable, cast from agent.redact import redact_sensitive_text @@ -105,6 +107,99 @@ def _telegram_retry_delay(exc: Exception, attempt: int) -> float | None: return None +def _coerce_bool(value) -> bool: + """Coerce common config/env boolean shapes.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) + + +def _telegram_rich_fallback_error(exc: Exception) -> bool: + """Return True when standalone sendRichMessage can safely downgrade. + + Capability/parser failures mean Telegram definitely rejected the rich + payload before delivery, so the legacy MarkdownV2 path is safe. Transient + network errors are *not* safe to resend because the rich request may have + reached Telegram and a fallback send would duplicate the message. + """ + name = exc.__class__.__name__.lower() + if name in {"endpointnotfound", "badrequest", "invalidtoken"}: + return True + if isinstance(exc, (AttributeError, TypeError, NotImplementedError)): + return True + if getattr(exc, "error_code", None) == 404: + return True + text = str(exc).lower() + return any( + marker in text + for marker in ( + "bad request", + "can't parse", + "cannot parse", + "parse entities", + "markdown", + "html", + "no such method", + "method not found", + "endpoint not found", + "unsupported", + "not implemented", + ) + ) + + +def _telegram_message_id(message_or_response) -> str | None: + """Extract a Telegram message id from PTB objects or raw Bot API dicts.""" + if isinstance(message_or_response, dict): + message_id = message_or_response.get("message_id") + if message_id is None and isinstance(message_or_response.get("result"), dict): + message_id = message_or_response["result"].get("message_id") + else: + message_id = getattr(message_or_response, "message_id", None) + return str(message_id) if message_id is not None else None + + +async def _try_send_telegram_rich_message( + bot, + *, + chat_id: int, + message: str, + thread_kwargs: dict, + disable_link_previews: bool = False, +): + """Best-effort standalone Bot API 10.1 sendRichMessage. + + Returns the raw Telegram response/message-like object on success, ``None`` + on permanent rich capability/parser rejection, and raises on transient + failures where retrying via legacy send could duplicate the message. + """ + do_api_request = getattr(bot, "do_api_request", None) + if not callable(do_api_request): + return None + payload = { + "chat_id": chat_id, + "rich_message": {"markdown": message}, + } + payload.update(thread_kwargs) + if disable_link_previews: + payload["link_preview_options"] = {"is_disabled": True} + try: + result = do_api_request("sendRichMessage", api_kwargs=payload) + if inspect.isawaitable(result): + result = await cast(Awaitable[object], result) + return result + except Exception as exc: + if _telegram_rich_fallback_error(exc): + logger.warning( + "Standalone Telegram sendRichMessage rejected, falling back to MarkdownV2: %s", + _sanitize_error_text(exc), + ) + return None + raise + + async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs): for attempt in range(attempts): try: @@ -760,7 +855,9 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, # --- Telegram: special handling for media attachments --- if platform == Platform.TELEGRAM: last_result = None - disable_link_previews = bool(getattr(pconfig, "extra", {}) and pconfig.extra.get("disable_link_previews")) + platform_extra = getattr(pconfig, "extra", {}) or {} + disable_link_previews = bool(platform_extra and platform_extra.get("disable_link_previews")) + rich_messages = _coerce_bool(platform_extra.get("rich_messages")) for i, chunk in enumerate(chunks): is_last = (i == len(chunks) - 1) result = await _send_telegram( @@ -771,6 +868,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, thread_id=thread_id, disable_link_previews=disable_link_previews, force_document=force_document, + rich_messages=rich_messages, ) if isinstance(result, dict) and result.get("error"): return result @@ -943,13 +1041,22 @@ def _is_telegram_thread_not_found(error: Exception) -> bool: return "thread not found" in str(error).lower() -async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False, force_document=False): +async def _send_telegram( + token, + chat_id, + message, + media_files=None, + thread_id=None, + disable_link_previews=False, + force_document=False, + rich_messages=False, +): """Send via Telegram Bot API (one-shot, no polling needed). - Applies markdown→MarkdownV2 formatting (same as the gateway adapter) - so that bold, links, and headers render correctly. If the message - already contains HTML tags, it is sent with ``parse_mode='HTML'`` - instead, bypassing MarkdownV2 conversion. + When ``rich_messages`` is enabled, text is sent through Bot API 10.1 + ``sendRichMessage`` with the raw Markdown so tables, task lists, headings, + and formulas can render natively. If rich delivery is unavailable or the + rich parser rejects the payload, falls back to the legacy MarkdownV2 path. """ try: from telegram import Bot @@ -1032,7 +1139,16 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No last_msg = None warnings = [] - if formatted.strip(): + if message.strip() and rich_messages and not _has_html: + last_msg = await _try_send_telegram_rich_message( + bot, + chat_id=int_chat_id, + message=message, + thread_kwargs=thread_kwargs, + disable_link_previews=disable_link_previews, + ) + + if last_msg is None and formatted.strip(): try: last_msg = await _send_telegram_message_with_retry( bot, @@ -1152,11 +1268,13 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return {"error": error, "warnings": warnings} return {"error": error} + message_id = _telegram_message_id(last_msg) + result = { "success": True, "platform": "telegram", "chat_id": chat_id, - "message_id": str(last_msg.message_id), + "message_id": message_id, } if warnings: result["warnings"] = warnings