diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index dd4632e13f..9587298b4e 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 bc840da90c..446a3e1b96 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -320,6 +320,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 # ========================================================================= @@ -418,6 +507,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):