"""Tests for Telegram MarkdownV2 formatting in gateway/platforms/telegram.py. Covers: _escape_mdv2 (pure function), format_message (markdown-to-MarkdownV2 conversion pipeline), and edge cases that could produce invalid MarkdownV2 or corrupt user-visible content. """ import re from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from gateway.config import PlatformConfig # --------------------------------------------------------------------------- # Mock the telegram package if it's not installed # --------------------------------------------------------------------------- from plugins.platforms.telegram.adapter import ( # noqa: E402 TelegramAdapter, _escape_mdv2, _strip_mdv2, _wrap_markdown_tables, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def adapter(): config = PlatformConfig(enabled=True, token="fake-token") return TelegramAdapter(config) # ========================================================================= # _escape_mdv2 # ========================================================================= class TestEscapeMdv2: def test_escapes_all_special_characters(self): special = r'_*[]()~`>#+-=|{}.!\ ' escaped = _escape_mdv2(special) # Every special char should be preceded by backslash for ch in r'_*[]()~`>#+-=|{}.!\ ': if ch == ' ': continue assert f'\\{ch}' in escaped # ========================================================================= # format_message - basic conversions # ========================================================================= class TestFormatMessageBasic: def test_plain_text_specials_escaped(self, adapter): result = adapter.format_message("Price is $5.00!") assert "\\." in result assert "\\!" in result # ========================================================================= # format_message - code blocks # ========================================================================= class TestFormatMessageCodeBlocks: def test_code_block_special_chars_not_escaped(self, adapter): text = "```\nif (x > 0) { return !x; }\n```" result = adapter.format_message(text) # Inside code block, > and ! and { should NOT be escaped assert "if (x > 0) { return !x; }" in result def test_inline_code_special_chars_not_escaped(self, adapter): text = "Run `rm -rf ./*` carefully" result = adapter.format_message(text) assert "`rm -rf ./*`" in result def test_inline_code_backslashes_escaped(self, adapter): r"""Backslashes in inline code must be escaped for MarkdownV2.""" text = r"Check `C:\ProgramData\VMware\` path" result = adapter.format_message(text) assert r"`C:\\ProgramData\\VMware\\`" in result def test_fenced_code_block_backslashes_escaped(self, adapter): r"""Backslashes in fenced code blocks must be escaped for MarkdownV2.""" text = "```\npath = r'C:\\Users\\test'\n```" result = adapter.format_message(text) assert r"C:\\Users\\test" in result def test_fenced_code_block_backticks_escaped(self, adapter): r"""Backticks inside fenced code blocks must be escaped for MarkdownV2.""" text = "```\necho `hostname`\n```" result = adapter.format_message(text) assert r"echo \`hostname\`" in result def test_inline_code_no_double_escape(self, adapter): r"""Already-escaped backslashes should not be quadruple-escaped.""" text = r"Use `\\server\share`" result = adapter.format_message(text) # \\ in input ā \\\\ in output (each \ escaped once) assert r"`\\\\server\\share`" in result def test_inline_triple_backticks_not_treated_as_fence(self, adapter): r"""Inline ``` spans must not be swallowed by the fenced-block regex. Regression for the over-matching fence regex that turned a single-line inline triple-backtick span into a mangled MarkdownV2
entity
(Telegram: "can't find end of pre entity"), forcing a plain-text
fallback that dropped all rich formatting.
"""
text = "the syntax is ```like this``` inline"
result = adapter.format_message(text)
# Content is preserved, and the inline span is NOT emitted as a raw
# fenced block: no unescaped triple-backtick run survives to open an
# unbalanced entity (the literal backticks are escaped instead).
assert "like this" in result
assert "```" not in result
def test_fence_and_inline_backticks_mixed(self, adapter):
r"""A real fenced block still gets protected even when the same message
also contains inline triple backticks elsewhere."""
text = "```\nx = 1\n```\nand inline ```y``` after"
result = adapter.format_message(text)
# The standalone fenced block is protected verbatim...
assert "```\nx = 1\n```" in result
# ...and the trailing inline ```y``` does not spawn a second,
# unbalanced fence ā only the real block's two fences remain.
assert result.count("```") == 2
assert "y" in result
def test_fence_with_trailing_whitespace_on_close(self, adapter):
r"""A closing fence with trailing spaces must not double up the ```."""
text = "```\ncode\n``` "
result = adapter.format_message(text)
# Exactly one closing fence, not "``````".
assert "``````" not in result
assert "code" in result
def test_fence_with_crlf_line_endings_is_protected(self, adapter):
r"""A fenced block whose lines end in CRLF (``\r\n``) ā e.g. content
that originated on Windows ā must still be protected. The closing-fence
anchor tolerates a trailing ``\r`` before the line end; without it the
block goes unrecognized and its backticks are escaped into literal
``\``` runs, dropping the code formatting."""
text = "```\r\ncode\r\n```\r\n"
result = adapter.format_message(text)
# Protected as a real fence: the body survives and the fence backticks
# are emitted literally, not escaped (which is what an unmatched close
# would produce).
assert "code" in result
assert "\\`" not in result
def test_midline_opened_fence_stays_protected(self, adapter):
r"""A fence opened after lead-in prose on the same line ("Here is the
code: ```") is a real block on main and must stay one: the
line-start-only anchor downgraded it to escaped literal prose."""
text = "Here is the code: ```python\nprint('hi')\n```"
result = adapter.format_message(text)
assert "```python\nprint('hi')\n```" in result
assert "\\`" not in result
def test_list_nested_indented_fence_stays_protected(self, adapter):
r"""Fences indented by 4+ spaces (code nested in lists/blockquotes) are
real blocks on main and must stay one regardless of indent."""
text = "- item:\n ```\n code\n ```"
result = adapter.format_message(text)
assert " ```\n code\n ```" in result
assert "\\`" not in result
def test_midline_fence_after_inline_code_lead_in(self, adapter):
r"""An inline code span in the lead-in must not stop the fence opened
later on the same line from being protected."""
text = "use `foo` then: ```python\nx\n```"
result = adapter.format_message(text)
assert "```python\nx\n```" in result
assert "`foo`" in result
assert "\\`" not in result
def test_inline_pair_then_real_fence_both_handled(self, adapter):
r"""An inline triple-backtick pair on one line must not swallow a real
line-start fence that follows it (the pre-anchored regex over-matched
from the inline pair; the old unanchored regex matched from it too)."""
text = "the syntax is ```like this``` inline\n```python\nx\n```"
result = adapter.format_message(text)
# The real fence is protected verbatim...
assert "```python\nx\n```" in result
# ...the inline pair is escaped as literal text (no raw ``` for it)...
assert "like this" in result
# ...and exactly the one real block's two fences remain.
assert result.count("```") == 2
@pytest.mark.asyncio
async def test_final_send_does_not_retrigger_typing(adapter):
"""The final reply (metadata['notify']) must NOT re-arm Telegram's typing
timer. The gateway has already torn down the refresh loop by then, so a
re-trigger here would leave the '...typing' bubble lingering after the
answer (Telegram has no stop-typing API). See #48678."""
adapter._bot = MagicMock()
adapter._bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1))
adapter._bot.send_chat_action = AsyncMock()
adapter._rich_messages_enabled = False
result = await adapter.send("12345", "All done.", metadata={"notify": True})
assert result.success is True
adapter._bot.send_chat_action.assert_not_called()
# =========================================================================
# format_message - bold and italic
# =========================================================================
class TestFormatMessageBoldItalic:
def test_bold_converted(self, adapter):
result = adapter.format_message("This is **bold** text")
# MarkdownV2 bold uses single *
assert "*bold*" in result
# Original ** should be gone
assert "**" not in result
def test_reload_mcp_summary_escapes_dynamic_server_names(self, adapter):
content = (
"š **MCP Servers Reloaded**\n"
"ā»ļø Reconnected: agent_one, tool[beta]\n"
"ā Added: alpha*prod\n"
"š§ 3 tool(s) available from 2 server(s)"
)
result = adapter.format_message(content)
assert "*MCP Servers Reloaded*" in result
assert "agent\\_one" in result
assert "tool\\[beta\\]" in result
assert "alpha\\*prod" in result
# =========================================================================
# format_message - headers
# =========================================================================
class TestFormatMessageHeaders:
def test_h1_converted_to_bold(self, adapter):
result = adapter.format_message("# Title")
# Header becomes bold in MarkdownV2
assert "*Title*" in result
# Hash should be removed
assert "#" not in result
def test_header_with_inner_bold_stripped(self, adapter):
# Headers strip redundant **...** inside
result = adapter.format_message("## **Important**")
# Should be *Important* not ***Important***
assert "*Important*" in result
count = result.count("*")
# Should have exactly 2 asterisks (open + close)
assert count == 2
# =========================================================================
# format_message - links
# =========================================================================
class TestFormatMessageLinks:
def test_link_display_text_escaped(self, adapter):
result = adapter.format_message("[Hello!](https://example.com)")
# The ! in display text should be escaped
assert "Hello\\!" in result
def test_link_url_parentheses_escaped(self, adapter):
result = adapter.format_message("[link](https://example.com/path_(1))")
# The ) in URL should be escaped
assert "\\)" in result
# =========================================================================
# format_message - BUG: italic regex spans newlines
# =========================================================================
class TestItalicNewlineBug:
r"""Italic regex ``\*([^*]+)\*`` matched across newlines, corrupting content.
This affects bullet lists using * markers and any text where * appears
at the end of one line and start of another.
"""
def test_bullet_list_not_corrupted(self, adapter):
"""Bullet list items using * must NOT be merged into italic."""
text = "* Item one\n* Item two\n* Item three"
result = adapter.format_message(text)
# Each item should appear in the output (not eaten by italic conversion)
assert "Item one" in result
assert "Item two" in result
assert "Item three" in result
# Should NOT contain _ (italic markers) wrapping list items
assert "_" not in result or "Item" not in result.split("_")[1] if "_" in result else True
# =========================================================================
# format_message - strikethrough
# =========================================================================
class TestFormatMessageStrikethrough:
def test_strikethrough_converted(self, adapter):
result = adapter.format_message("This is ~~deleted~~ text")
assert "~deleted~" in result
assert "~~" not in result
# =========================================================================
# format_message - spoiler
# =========================================================================
class TestFormatMessageSpoiler:
def test_spoiler_pipes_not_escaped(self, adapter):
"""The || delimiters must not be escaped as \\|\\|."""
result = adapter.format_message("||secret||")
assert "\\|\\|" not in result
assert "||secret||" in result
# =========================================================================
# format_message - blockquote
# =========================================================================
class TestFormatMessageBlockquote:
def test_blockquote_multiline(self, adapter):
text = "> Line one\n> Line two"
result = adapter.format_message(text)
assert "> Line one" in result
assert "> Line two" in result
assert "\\>" not in result
def test_gt_in_middle_of_line_still_escaped(self, adapter):
"""Only > at line start is a blockquote; mid-line > should be escaped."""
result = adapter.format_message("5 > 3")
assert "\\>" in result
def test_regular_blockquote_with_pipes_escaped(self, adapter):
"""Regular blockquote ending with || should escape the pipes."""
result = adapter.format_message("> not expandable||")
assert "> not expandable" in result
assert "\\|" in result
assert "\\>" not in result
# =========================================================================
# format_message - mixed/complex
# =========================================================================
class TestFormatMessageComplex:
def test_code_block_with_bold_outside(self, adapter):
text = "**Note:**\n```\ncode here\n```"
result = adapter.format_message(text)
assert "*Note:*" in result or "*Note\\:*" in result
assert "```\ncode here\n```" in result
def test_bold_inside_code_not_converted(self, adapter):
"""Bold markers inside code blocks should not be converted."""
text = "```\n**not bold**\n```"
result = adapter.format_message(text)
assert "**not bold**" in result
def test_empty_bold(self, adapter):
"""**** (empty bold) should not crash."""
result = adapter.format_message("****")
assert result is not None
# =========================================================================
# _strip_mdv2 ā plaintext fallback
# =========================================================================
class TestStripMdv2:
def test_removes_escape_backslashes(self):
assert _strip_mdv2(r"hello\.world\!") == "hello.world!"
def test_removes_both_bold_and_italic(self):
result = _strip_mdv2("*bold* and _italic_")
assert result == "bold and italic"
def test_preserves_snake_case(self):
assert _strip_mdv2("my_variable_name") == "my_variable_name"
# =========================================================================
# Markdown table auto-wrap
# =========================================================================
class TestWrapMarkdownTables:
"""_wrap_markdown_tables rewrites GFM pipe tables into Telegram-friendly
row groups instead of leaving noisy pipe syntax in the final message."""
def test_basic_table_rewritten_as_row_groups(self):
text = (
"Scores:\n\n"
"| Player | Score |\n"
"|--------|-------|\n"
"| Alice | 150 |\n"
"| Bob | 120 |\n"
"\nEnd."
)
out = _wrap_markdown_tables(text)
assert "**Alice**" in out
# The heading IS the Player cell ā don't repeat it as a bullet.
assert "⢠Player: Alice" not in out
assert "⢠Score: 150" in out
assert "**Bob**" in out
assert "⢠Score: 120" in out
# Heading and its bullet sit on consecutive lines (no blank between).
assert "**Alice**\n⢠Score: 150" in out
# Separate row groups ARE separated by a blank line.
assert "⢠Score: 150\n\n**Bob**" in out
# Surrounding prose is preserved
assert out.startswith("Scores:")
assert out.endswith("End.")
def test_no_pipe_character_short_circuits(self):
text = "Plain **bold** text with no table."
assert _wrap_markdown_tables(text) == text
def test_row_group_uses_single_newlines_within_group(self):
"""Regression: each bullet within a row-group must be separated by
a single newline, not a blank line. Telegram renders blank lines
as paragraph breaks, which previously left every bullet floating in
its own paragraph and made multi-column tables unreadable.
Mirrors the exact pattern that produced the screenshot bug report:
a five-column comparison table with no row-label column.
"""
text = (
"| Play | Capital | Build | $/day | Risk |\n"
"|---|---|---|---|---|\n"
"| A. Copy Hands (HK/SZ) | $5-10k | 2 wk | $30-70 | Low |\n"
"| B. NO-sweeper | $50-100k | 3 wk | $300-1000 | Med |"
)
out = _wrap_markdown_tables(text)
# No bullet sits inside its own paragraph: the substring "\n\n⢠"
# would mean a blank line precedes a bullet, which is the bug.
assert "\n\n⢠" not in out
# The two row-groups DO have a paragraph break between them.
groups = [g for g in out.split("\n\n") if g.strip()]
assert len(groups) == 2
# Heading + 4 bullets per group means each group is exactly 5 lines.
for group in groups:
line_count = group.count("\n") + 1
assert line_count == 5, (
"Each row-group should be 5 lines (heading + 4 bullets), "
f"got {line_count}:\n{group}"
)
class TestFormatMessageTables:
"""End-to-end: pipe tables become readable Telegram-native text instead
of escaped pipe syntax or fenced code blocks."""
def test_table_rendered_as_bullets(self, adapter):
text = (
"Data:\n\n"
"| Col1 | Col2 |\n"
"|------|------|\n"
"| A | B |\n"
)
out = adapter.format_message(text)
assert "*A*" in out
# Heading 'A' duplicates the Col1 value ā skip that bullet.
assert "⢠Col1: A" not in out
assert "⢠Col2: B" in out
assert "```" not in out
assert "\\|" not in out
@pytest.mark.asyncio
async def test_send_escapes_chunk_indicator_for_markdownv2(adapter):
adapter.MAX_MESSAGE_LENGTH = 80
adapter._bot = MagicMock()
sent_texts = []
async def _fake_send_message(**kwargs):
sent_texts.append(kwargs["text"])
msg = MagicMock()
msg.message_id = len(sent_texts)
return msg
adapter._bot.send_message = AsyncMock(side_effect=_fake_send_message)
content = ("**bold** chunk content " * 12).strip()
result = await adapter.send("123", content)
assert result.success is True
assert len(sent_texts) > 1
assert re.search(r" \\\([0-9]+/[0-9]+\\\)$", sent_texts[0])
assert re.search(r" \\\([0-9]+/[0-9]+\\\)$", sent_texts[-1])
# =========================================================================
# edit_message ā streaming Markdown safety
# =========================================================================
class TestEditMessageStreamingSafety:
@pytest.mark.asyncio
async def test_message_too_long_splits_into_continuations_not_silent_truncation(self):
"""When edit_message_text exceeds Telegram's 4096 UTF-16 limit on
finalize, the adapter must split the content across the existing
message + new continuation messages so the user gets the full reply.
Previously the adapter best-effort truncated the content with 'ā¦' and
returned success=True, dropping everything past the truncation
boundary (#19537)."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock()
# Continuation sends return monotonically increasing message ids.
_next_id = [1000]
async def _fake_send(**kwargs):
_next_id[0] += 1
return SimpleNamespace(message_id=_next_id[0])
adapter._bot.send_message = AsyncMock(side_effect=_fake_send)
# 6000-char content well over the 4096 UTF-16 limit.
oversized = "x" * 6000
result = await adapter.edit_message("123", "456", oversized, finalize=True)
# Adapter reports success with continuations populated.
assert result.success is True
assert result.error is None
assert len(result.continuation_message_ids) >= 1, (
"expected at least one continuation message"
)
# The reported message_id is the LAST visible message (the final
# continuation), so subsequent edits target the most recent.
assert result.message_id == result.continuation_message_ids[-1]
# Original message_id (456) was edited with chunk 1.
first_edit = adapter._bot.edit_message_text.call_args
assert first_edit.kwargs["message_id"] == 456
# Continuations were sent threaded as replies for visual grouping.
assert adapter._bot.send_message.await_count == len(result.continuation_message_ids)
@pytest.mark.asyncio
async def test_mid_stream_overflow_truncates_instead_of_splitting(self):
"""During streaming (finalize=False), oversized content must be
truncated to fit one message rather than split into continuations.
Splitting mid-stream creates new message IDs that the stream consumer
then edits with the full accumulated text, causing an infinite
duplication loop (#48648)."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock()
_next_id = [1000]
async def _fake_send(**kwargs):
_next_id[0] += 1
return SimpleNamespace(message_id=_next_id[0])
adapter._bot.send_message = AsyncMock(side_effect=_fake_send)
oversized = "x" * 6000
result = await adapter.edit_message("123", "456", oversized, finalize=False)
# Must NOT create continuation messages during streaming.
assert result.success is True
assert adapter._bot.send_message.await_count == 0, (
"mid-stream overflow must not send continuation messages"
)
# message_id stays the original ā no shift to a new ID.
assert result.message_id == "456"
# The edit must contain truncated content (⤠MAX_MESSAGE_LENGTH).
edited_text = adapter._bot.edit_message_text.call_args.kwargs["text"]
assert len(edited_text) <= adapter.MAX_MESSAGE_LENGTH
@pytest.mark.asyncio
async def test_saturated_preview_dedups_repeat_oversized_edits(self):
"""Once a mid-stream preview saturates at the truncation cap, further
oversized edits truncate to the SAME text ā re-sending them is a
visual no-op that burns Telegram's flood budget (~1 edit/0.8s for the
rest of a long stream ā flood control with 200s+ penalties, hanging
final delivery). The adapter must skip identical saturated previews
without an API call."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="fake-token"))
adapter._bot = MagicMock()
adapter._bot.edit_message_text = AsyncMock()
adapter._bot.send_message = AsyncMock()
# First oversized edit: delivers the truncated preview (1 API call).
r1 = await adapter.edit_message("123", "456", "x" * 6000, finalize=False)
assert r1.success is True
assert adapter._bot.edit_message_text.await_count == 1
# Stream keeps growing within the same chunk count: previews truncate
# identically ā no API calls. (7000 and 8000 chars both truncate to
# the same "ā¦(1/2)" preview; 9000 crosses into "(1/3)" ā a real change
# that SHOULD be delivered, at most one edit per ~4096 chars of growth
# instead of one per 0.8s tick.)
for grow in (7000, 8000):
r = await adapter.edit_message("123", "456", "x" * grow, finalize=False)
assert r.success is True
assert r.message_id == "456"
assert adapter._bot.edit_message_text.await_count == 1, (
"identical saturated previews must not be re-sent"
)
# Chunk-count boundary: marker changes (1/2 ā 1/3) ā one real edit.
await adapter.edit_message("123", "456", "x" * 9000, finalize=False)
assert adapter._bot.edit_message_text.await_count == 2
# ...and saturates again at the new marker.
await adapter.edit_message("123", "456", "x" * 9100, finalize=False)
assert adapter._bot.edit_message_text.await_count == 2
# A DIFFERENT oversized prefix (content changed within the first 4096)
# must still go through.
await adapter.edit_message("123", "456", "y" * 9100, finalize=False)
assert adapter._bot.edit_message_text.await_count == 3
# =========================================================================
# Telegram guest mention gating
# =========================================================================
def _guest_test_adapter(*, guest_mode=True, require_mention=True, allowed_chats=None):
config = PlatformConfig(
enabled=True,
token="fake-token",
extra={
"guest_mode": guest_mode,
"require_mention": require_mention,
"allowed_chats": allowed_chats or ["-100200"],
},
)
adapter = object.__new__(TelegramAdapter)
adapter.config = config
adapter._bot = SimpleNamespace(id=999, username="hermes_bot")
adapter._mention_patterns = adapter._compile_mention_patterns()
# PR db50af910 added a TELEGRAM_ALLOWED_USERS allowlist gate to
# _should_process_message. These tests aren't exercising the auth
# gate ā they're exercising the guest-mode mention/allowed_chats
# logic that runs after ā so stub the user authz to always allow.
adapter._is_callback_user_authorized = lambda *_a, **_kw: True
return adapter
def _guest_group_message(text, *, chat_id=-100201, entities=None, reply_to_bot=False):
reply_to_message = SimpleNamespace(from_user=SimpleNamespace(id=999)) if reply_to_bot else None
return SimpleNamespace(
text=text,
caption=None,
entities=entities or [],
caption_entities=[],
message_thread_id=None,
chat=SimpleNamespace(id=chat_id, type="group"),
from_user=SimpleNamespace(id=111),
reply_to_message=reply_to_message,
)
def _guest_mention_entity(text, mention="@hermes_bot"):
return SimpleNamespace(type="mention", offset=text.index(mention), length=len(mention))
class TestTelegramGuestMentionGating:
def test_guest_mode_allows_bot_command_entity_outside_allowed_chats(self):
"""``/cmd@botname`` is a ``bot_command`` entity, not ``mention``."""
adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"])
text = "/status@hermes_bot"
message = _guest_group_message(
text,
chat_id=-100201,
entities=[SimpleNamespace(type="bot_command", offset=0, length=len(text))],
)
assert adapter._should_process_message(message) is True
def test_guest_mode_allows_mention_in_caption_outside_allowed_chats(self):
"""Media caption @mention should bypass allowed_chats via guest_mode."""
adapter = _guest_test_adapter(guest_mode=True, allowed_chats=["-100200"])
text = "look @hermes_bot"
message = _guest_group_message(
text="",
chat_id=-100201,
entities=[],
)
message.caption = text
message.caption_entities = [_guest_mention_entity(text)]
assert adapter._should_process_message(message) is True