From 02f639e5616389ff9589afc2939f7eee959ae6c6 Mon Sep 17 00:00:00 2001 From: llbn <46884939+llbn@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:21:24 +0100 Subject: [PATCH] fix(telegram): add MarkdownV2 support for strikethrough, spoiler, and blockquotes - Convert ~~text~~ to ~text~ (MarkdownV2 strikethrough) - Protect ||text|| from pipe escaping (MarkdownV2 spoiler) - Preserve > at line start as blockquote instead of escaping it - Update _strip_mdv2() to strip ~strikethrough~ and ||spoiler|| markers - Add tests covering new formatting paths and edge cases --- gateway/platforms/telegram.py | 34 ++++++++-- tests/gateway/test_telegram_format.py | 95 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index fe869f18e1..df6add515c 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -79,8 +79,8 @@ def _escape_mdv2(text: str) -> str: def _strip_mdv2(text: str) -> str: """Strip MarkdownV2 escape backslashes to produce clean plain text. - Also removes MarkdownV2 bold markers (*text* -> text) so the fallback - doesn't show stray asterisks from header/bold conversion. + Also removes MarkdownV2 formatting markers so the fallback + doesn't show stray syntax characters from format_message conversion. """ # Remove escape backslashes before special characters cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text) @@ -89,6 +89,10 @@ def _strip_mdv2(text: str) -> str: # Remove MarkdownV2 italic markers that format_message converted from *italic* # Use word boundary (\b) to avoid breaking snake_case like my_variable_name cleaned = re.sub(r'(? at line start → protect > from escaping + text = re.sub( + r'^(>{1,3}) (.+)$', + lambda m: _ph(m.group(1) + ' ' + _escape_mdv2(m.group(2))), + text, + flags=re.MULTILINE, + ) + + # 10) Escape remaining special characters in plain text text = _escape_mdv2(text) - # 8) Restore placeholders in reverse insertion order so that + # 11) Restore placeholders in reverse insertion order so that # nested references (a placeholder inside another) resolve correctly. for key in reversed(list(placeholders.keys())): text = text.replace(key, placeholders[key]) diff --git a/tests/gateway/test_telegram_format.py b/tests/gateway/test_telegram_format.py index 19e56198b3..5d59776d74 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -295,6 +295,95 @@ class TestItalicNewlineBug: assert "_italic_" in result +# ========================================================================= +# 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 + + def test_strikethrough_with_special_chars(self, adapter): + result = adapter.format_message("~~hello.world!~~") + assert "~hello\\.world\\!~" in result + + def test_strikethrough_in_code_not_converted(self, adapter): + result = adapter.format_message("`~~not struck~~`") + assert "`~~not struck~~`" in result + + def test_strikethrough_with_bold(self, adapter): + result = adapter.format_message("**bold** and ~~struck~~") + assert "*bold*" in result + assert "~struck~" in result + + +# ========================================================================= +# format_message - spoiler +# ========================================================================= + + +class TestFormatMessageSpoiler: + def test_spoiler_converted(self, adapter): + result = adapter.format_message("This is ||hidden|| text") + assert "||hidden||" in result + + def test_spoiler_with_special_chars(self, adapter): + result = adapter.format_message("||hello.world!||") + assert "||hello\\.world\\!||" in result + + def test_spoiler_in_code_not_converted(self, adapter): + result = adapter.format_message("`||not spoiler||`") + assert "`||not spoiler||`" in result + + 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_converted(self, adapter): + result = adapter.format_message("> This is a quote") + assert "> This is a quote" in result + # > must NOT be escaped + assert "\\>" not in result + + def test_blockquote_with_special_chars(self, adapter): + result = adapter.format_message("> Hello (world)!") + assert "> Hello \\(world\\)\\!" in result + assert "\\>" not in result + + 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_blockquote_in_code_not_converted(self, adapter): + result = adapter.format_message("```\n> not a quote\n```") + assert "> not a quote" in result + + def test_nested_blockquote(self, adapter): + result = adapter.format_message(">> Nested quote") + assert ">> Nested quote" 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 + + # ========================================================================= # format_message - mixed/complex # ========================================================================= @@ -393,6 +482,12 @@ class TestStripMdv2: def test_empty_string(self): assert _strip_mdv2("") == "" + def test_removes_strikethrough_markers(self): + assert _strip_mdv2("~struck text~") == "struck text" + + def test_removes_spoiler_markers(self): + assert _strip_mdv2("||hidden text||") == "hidden text" + @pytest.mark.asyncio async def test_send_escapes_chunk_indicator_for_markdownv2(adapter):