diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 7ef9b149d3..2b03847e5c 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -25,7 +25,7 @@ def _make_config(): def _install_telegram_mock(monkeypatch, bot): - parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2") + parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") constants_mod = SimpleNamespace(ParseMode=parse_mode) telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod) monkeypatch.setitem(sys.modules, "telegram", telegram_mod) @@ -391,3 +391,97 @@ class TestSendToPlatformChunking: assert len(sent_calls) >= 3 assert all(call == [] for call in sent_calls[:-1]) assert sent_calls[-1] == media + + +# --------------------------------------------------------------------------- +# HTML auto-detection in Telegram send +# --------------------------------------------------------------------------- + + +class TestSendTelegramHtmlDetection: + """Verify that messages containing HTML tags are sent with parse_mode=HTML + and that plain / markdown messages use MarkdownV2.""" + + def _make_bot(self): + bot = MagicMock() + bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) + bot.send_photo = AsyncMock() + bot.send_video = AsyncMock() + bot.send_voice = AsyncMock() + bot.send_audio = AsyncMock() + bot.send_document = AsyncMock() + return bot + + def test_html_message_uses_html_parse_mode(self, monkeypatch): + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + asyncio.run( + _send_telegram("tok", "123", "Hello world") + ) + + bot.send_message.assert_awaited_once() + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "HTML" + assert kwargs["text"] == "Hello world" + + def test_plain_text_uses_markdown_v2(self, monkeypatch): + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + asyncio.run( + _send_telegram("tok", "123", "Just plain text, no tags") + ) + + bot.send_message.assert_awaited_once() + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "MarkdownV2" + + def test_html_with_code_and_pre_tags(self, monkeypatch): + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + html = "
code block
and inline" + asyncio.run(_send_telegram("tok", "123", html)) + + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "HTML" + + def test_closing_tag_detected(self, monkeypatch): + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + asyncio.run(_send_telegram("tok", "123", "text more")) + + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "HTML" + + def test_angle_brackets_in_math_not_detected(self, monkeypatch): + """Expressions like 'x < 5' or '3 > 2' should not trigger HTML mode.""" + bot = self._make_bot() + _install_telegram_mock(monkeypatch, bot) + + asyncio.run(_send_telegram("tok", "123", "if x < 5 then y > 2")) + + kwargs = bot.send_message.await_args.kwargs + assert kwargs["parse_mode"] == "MarkdownV2" + + def test_html_parse_failure_falls_back_to_plain(self, monkeypatch): + """If Telegram rejects the HTML, fall back to plain text.""" + bot = self._make_bot() + bot.send_message = AsyncMock( + side_effect=[ + Exception("Bad Request: can't parse entities: unsupported html tag"), + SimpleNamespace(message_id=2), # plain fallback succeeds + ] + ) + _install_telegram_mock(monkeypatch, bot) + + result = asyncio.run( + _send_telegram("tok", "123", "broken html") + ) + + assert result["success"] is True + assert bot.send_message.await_count == 2 + second_call = bot.send_message.await_args_list[1].kwargs + assert second_call["parse_mode"] is None diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index e3bac45a24..4b0c4815ff 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -355,20 +355,31 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No """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. + 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. """ try: from telegram import Bot from telegram.constants import ParseMode - # Reuse the gateway adapter's format_message for markdown→MarkdownV2 - try: - from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2 - _adapter = TelegramAdapter.__new__(TelegramAdapter) - formatted = _adapter.format_message(message) - except Exception: - # Fallback: send as-is if formatting unavailable + # Auto-detect HTML tags — if present, skip MarkdownV2 and send as HTML. + # Inspired by github.com/ashaney — PR #1568. + _has_html = bool(re.search(r'<[a-zA-Z/][^>]*>', message)) + + if _has_html: formatted = message + send_parse_mode = ParseMode.HTML + else: + # Reuse the gateway adapter's format_message for markdown→MarkdownV2 + try: + from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2 + _adapter = TelegramAdapter.__new__(TelegramAdapter) + formatted = _adapter.format_message(message) + except Exception: + # Fallback: send as-is if formatting unavailable + formatted = message + send_parse_mode = ParseMode.MARKDOWN_V2 bot = Bot(token=token) int_chat_id = int(chat_id) @@ -384,16 +395,19 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No try: last_msg = await bot.send_message( chat_id=int_chat_id, text=formatted, - parse_mode=ParseMode.MARKDOWN_V2, **thread_kwargs + parse_mode=send_parse_mode, **thread_kwargs ) except Exception as md_error: - # MarkdownV2 failed, fall back to plain text - if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower(): - logger.warning("MarkdownV2 parse failed in _send_telegram, falling back to plain text: %s", md_error) - try: - from gateway.platforms.telegram import _strip_mdv2 - plain = _strip_mdv2(formatted) - except Exception: + # Parse failed, fall back to plain text + if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower() or "html" in str(md_error).lower(): + logger.warning("Parse mode %s failed in _send_telegram, falling back to plain text: %s", send_parse_mode, md_error) + if not _has_html: + try: + from gateway.platforms.telegram import _strip_mdv2 + plain = _strip_mdv2(formatted) + except Exception: + plain = message + else: plain = message last_msg = await bot.send_message( chat_id=int_chat_id, text=plain,