Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 140 additions & 1 deletion tests/tools/test_send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))}

Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
134 changes: 126 additions & 8 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
"""

import asyncio
import inspect
import json
import logging
import os
import re
import ssl
import time
from email.utils import formatdate
from typing import Awaitable, cast

from agent.redact import redact_sensitive_text

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down