From 8c513a01f22398541563ebee46ee64fea7fdd112 Mon Sep 17 00:00:00 2001 From: llbn <46884939+llbn@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:23:30 +0100 Subject: [PATCH] fix(telegram): switch formatting from MarkdownV2 to HTML - Replace _escape_mdv2/_strip_mdv2 with _escape_html/_strip_html - Refactor format_message() pipeline to produce Telegram HTML tags - Reverse send flow to chunk-then-format, fixing invalid chunks caused by unescaped (N/M) indicators in formatted MarkdownV2 - Add blockquote, strikethrough, table, and horizontal rule support - Rewrite telegram-format tests for HTML assertions --- gateway/platforms/telegram.py | 180 +++++----- tests/gateway/test_telegram_format.py | 461 ++++++++++++++++++-------- 2 files changed, 425 insertions(+), 216 deletions(-) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 7d289a0a409c4..a50b9cd771726 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -66,30 +66,19 @@ def check_telegram_requirements() -> bool: return TELEGRAM_AVAILABLE -# Matches every character that MarkdownV2 requires to be backslash-escaped -# when it appears outside a code span or fenced code block. -_MDV2_ESCAPE_RE = re.compile(r'([_*\[\]()~`>#\+\-=|{}.!\\])') +# Matches any HTML tag (opening, closing, or self-closing) +_HTML_TAG_RE = re.compile(r'<[^>]+>') -def _escape_mdv2(text: str) -> str: - """Escape Telegram MarkdownV2 special characters with a preceding backslash.""" - return _MDV2_ESCAPE_RE.sub(r'\\\1', text) +def _escape_html(text: str) -> str: + """Escape &, <, > for Telegram HTML mode.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") -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. - """ - # Remove escape backslashes before special characters - cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text) - # Remove MarkdownV2 bold markers that format_message converted from **bold** - cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned) - # 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'(? str: + """Strip HTML tags and unescape entities for plain-text fallback.""" + cleaned = _HTML_TAG_RE.sub('', text) + return cleaned.replace("<", "<").replace(">", ">").replace("&", "&") class TelegramAdapter(BasePlatformAdapter): @@ -98,13 +87,13 @@ class TelegramAdapter(BasePlatformAdapter): Handles: - Receiving messages from users and groups - - Sending responses with Telegram markdown + - Sending responses with Telegram HTML formatting - Forum topics (thread_id support) - Media messages """ - # Telegram message limits MAX_MESSAGE_LENGTH = 4096 + HTML_OVERHEAD_RESERVE = 500 MEDIA_GROUP_WAIT_SECONDS = 0.8 def __init__(self, config: PlatformConfig): @@ -319,39 +308,38 @@ async def send( return SendResult(success=False, error="Not connected") try: - # Format and split message if needed - formatted = self.format_message(content) - chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - + # Chunk raw markdown first, then format each chunk independently. + # This avoids splitting mid-HTML-tag which would break parsing. + chunks = self.truncate_message(content, self.MAX_MESSAGE_LENGTH - self.HTML_OVERHEAD_RESERVE) + message_ids = [] thread_id = metadata.get("thread_id") if metadata else None - + for i, chunk in enumerate(chunks): - # Try Markdown first, fall back to plain text if it fails + formatted = self.format_message(chunk) + # Try HTML first, fall back to plain text if parsing fails try: msg = await self._bot.send_message( chat_id=int(chat_id), - text=chunk, - parse_mode=ParseMode.MARKDOWN_V2, + text=formatted, + parse_mode=ParseMode.HTML, reply_to_message_id=int(reply_to) if reply_to and i == 0 else None, message_thread_id=int(thread_id) if thread_id else None, ) - except Exception as md_error: - # Markdown parsing failed, try plain text - if "parse" in str(md_error).lower() or "markdown" in str(md_error).lower(): - logger.warning("[%s] MarkdownV2 parse failed, falling back to plain text: %s", self.name, md_error) - # Strip MDV2 escape backslashes so the user doesn't - # see raw backslashes littered through the message. - plain_chunk = _strip_mdv2(chunk) + except Exception as html_error: + if "parse" in str(html_error).lower(): + logger.warning("[%s] HTML parse failed, falling back to plain text: %s", self.name, html_error) + # Strip HTML tags so the user doesn't see raw markup + plain_chunk = _strip_html(formatted) msg = await self._bot.send_message( chat_id=int(chat_id), text=plain_chunk, - parse_mode=None, # Plain text + parse_mode=None, reply_to_message_id=int(reply_to) if reply_to and i == 0 else None, message_thread_id=int(thread_id) if thread_id else None, ) else: - raise # Re-raise if not a parse error + raise message_ids.append(str(msg.message_id)) return SendResult( @@ -380,14 +368,14 @@ async def edit_message( chat_id=int(chat_id), message_id=int(message_id), text=formatted, - parse_mode=ParseMode.MARKDOWN_V2, + parse_mode=ParseMode.HTML, ) except Exception: - # Fallback: retry without markdown formatting + # Fallback: strip HTML tags and retry as plain text await self._bot.edit_message_text( chat_id=int(chat_id), message_id=int(message_id), - text=content, + text=_strip_html(formatted), ) return SendResult(success=True, message_id=message_id) except Exception as e: @@ -688,13 +676,15 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: return {"name": str(chat_id), "type": "dm", "error": str(e)} def format_message(self, content: str) -> str: - """ - Convert standard markdown to Telegram MarkdownV2 format. - - Protected regions (code blocks, inline code) are extracted first so - their contents are never modified. Standard markdown constructs - (headers, bold, italic, links) are translated to MarkdownV2 syntax, - and all remaining special characters are escaped. + """Convert standard markdown to Telegram HTML format. + + Protected regions (code blocks, inline code, links, tables) are + extracted into null-byte placeholders before any conversion runs, + so their contents are never double-escaped or mangled. Standard + markdown constructs (headers, bold, italic, strikethrough, + blockquotes, horizontal rules) are translated to the HTML tags + that Telegram supports, and all remaining plain text is escaped + for &, <, >. """ if not content: return content @@ -703,7 +693,7 @@ def format_message(self, content: str) -> str: counter = [0] def _ph(value: str) -> str: - """Stash *value* behind a placeholder token that survives escaping.""" + """Stash value behind a placeholder token that survives escaping.""" key = f"\x00PH{counter[0]}\x00" counter[0] += 1 placeholders[key] = value @@ -711,57 +701,95 @@ def _ph(value: str) -> str: text = content - # 1) Protect fenced code blocks (``` ... ```) + # Fenced code blocks ->
+ def _convert_fenced(m): + # ```lang\n...content...``` -> extract language tag and body + match = re.match(r'```(\w*)\n?([\s\S]*?)```', m.group(0)) + if not match: + return _ph(f'{_escape_html(m.group(0)[3:-3])}') + lang, code = match.group(1), match.group(2) + if code.endswith('\n'): + code = code[:-1] + escaped = _escape_html(code) + if lang: + return _ph(f'') + return _ph(f'{escaped}{escaped}') + + # Match ```...``` blocks, optional language tag on first line + text = re.sub(r'```(?:[^\n]*\n)?[\s\S]*?```', _convert_fenced, text) + + # Inline code ->: match `...` (non-empty, no nested backticks) text = re.sub( - r'(```(?:[^\n]*\n)?[\s\S]*?```)', - lambda m: _ph(m.group(0)), + r'`([^`]+)`', + lambda m: _ph(f'{_escape_html(m.group(1))}'), text, ) - # 2) Protect inline code (`...`) - text = re.sub(r'(`[^`]+`)', lambda m: _ph(m.group(0)), text) + # Markdown links -> : match [display](url) + text = re.sub( + r'\[([^\]]+)\]\(([^)]+)\)', + lambda m: _ph(f'{_escape_html(m.group(1))}'), + text, + ) - # 3) Convert markdown links – escape the display text; inside the URL - # only ')' and '\' need escaping per the MarkdownV2 spec. - def _convert_link(m): - display = _escape_mdv2(m.group(1)) - url = m.group(2).replace('\\', '\\\\').replace(')', '\\)') - return _ph(f'[{display}]({url})') + # Markdown tables ->: match rows with | columns followed by a |---| separator row + def _convert_table(m): + return _ph(f'{_escape_html(m.group(0))}') - text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', _convert_link, text) + text = re.sub( + r'^(?:\|.+\|\n)+(?:\|[-: |]+\|\n)(?:\|.+\|\n?)*', + _convert_table, + text, + flags=re.MULTILINE, + ) - # 4) Convert markdown headers (## Title) → bold *Title* + # Headers -> : match lines starting with 1-6 # chars def _convert_header(m): inner = m.group(1).strip() - # Strip redundant bold markers that may appear inside a header + # Strip redundant **bold** inside headers inner = re.sub(r'\*\*(.+?)\*\*', r'\1', inner) - return _ph(f'*{_escape_mdv2(inner)}*') + return _ph(f'{_escape_html(inner)}') + text = re.sub(r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE) + + # Blockquotes ->: merge consecutive > prefixed lines + def _convert_blockquote(m): + lines = m.group(0).split('\n') + # Strip leading > and optional space from each line + inner = '\n'.join(re.sub(r'^>\s?', '', line) for line in lines if line) + return _ph(f'{_escape_html(inner)}') + + text = re.sub(r'^(?:>.*\n?)+', _convert_blockquote, text, flags=re.MULTILINE) + + # Horizontal rules -> em-dash separator: match 3+ of -, *, or _ on own line + text = re.sub(r'^[-*_]{3,}\s*$', lambda m: _ph('\u2014\u2014\u2014'), text, flags=re.MULTILINE) + + # Strikethrough ->: match ~~text~~ text = re.sub( - r'^#{1,6}\s+(.+)$', _convert_header, text, flags=re.MULTILINE + r'~~(.+?)~~', + lambda m: _ph(f'{_escape_html(m.group(1))}'), + text, ) - # 5) Convert bold: **text** → *text* (MarkdownV2 bold) + # Bold -> : match **text** text = re.sub( r'\*\*(.+?)\*\*', - lambda m: _ph(f'*{_escape_mdv2(m.group(1))}*'), + lambda m: _ph(f'{_escape_html(m.group(1))}'), text, ) - # 6) Convert italic: *text* (single asterisk) → _text_ (MarkdownV2 italic) - # [^*\n]+ prevents matching across newlines (which would corrupt - # bullet lists using * markers and multi-line content). + # Italic -> : match *text* (single-line only, [^*\n]+ prevents newline spanning) text = re.sub( r'\*([^*\n]+)\*', - lambda m: _ph(f'_{_escape_mdv2(m.group(1))}_'), + lambda m: _ph(f'{_escape_html(m.group(1))}'), text, ) - # 7) Escape remaining special characters in plain text - text = _escape_mdv2(text) + # Escape remaining plain text + text = _escape_html(text) - # 8) Restore placeholders in reverse insertion order so that - # nested references (a placeholder inside another) resolve correctly. + # Restore placeholders in reverse 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 a47cf8b15f518..64ed2083ba26c 100644 --- a/tests/gateway/test_telegram_format.py +++ b/tests/gateway/test_telegram_format.py @@ -1,8 +1,8 @@ -"""Tests for Telegram MarkdownV2 formatting in gateway/platforms/telegram.py. +"""Tests for Telegram HTML 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. +Covers: _escape_html (pure function), format_message (markdown-to-HTML +conversion pipeline), _strip_html (plain-text fallback), and edge cases +that could produce invalid HTML or corrupt user-visible content. """ import re @@ -23,6 +23,7 @@ def _ensure_telegram_mock(): return mod = MagicMock() mod.ext.ContextTypes.DEFAULT_TYPE = type(None) + mod.constants.ParseMode.HTML = "HTML" mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2" mod.constants.ChatType.GROUP = "group" mod.constants.ChatType.SUPERGROUP = "supergroup" @@ -34,7 +35,7 @@ def _ensure_telegram_mock(): _ensure_telegram_mock() -from gateway.platforms.telegram import TelegramAdapter, _escape_mdv2, _strip_mdv2 # noqa: E402 +from gateway.platforms.telegram import TelegramAdapter, _escape_html, _strip_html # noqa: E402 # --------------------------------------------------------------------------- @@ -48,38 +49,34 @@ def adapter(): # ========================================================================= -# _escape_mdv2 +# _escape_html # ========================================================================= -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 +class TestEscapeHtml: + def test_escapes_ampersand(self): + assert _escape_html("a & b") == "a & b" + + def test_escapes_angle_brackets(self): + assert _escape_html("") == "<tag>" def test_empty_string(self): - assert _escape_mdv2("") == "" + assert _escape_html("") == "" def test_no_special_characters(self): - assert _escape_mdv2("hello world 123") == "hello world 123" - - def test_backslash_escaped(self): - assert _escape_mdv2("a\\b") == "a\\\\b" + assert _escape_html("hello world 123") == "hello world 123" - def test_dot_escaped(self): - assert _escape_mdv2("v2.0") == "v2\\.0" + def test_no_double_escaping(self): + # & must be escaped first; pre-escaped entities should not collapse + assert _escape_html("&") == "&" - def test_exclamation_escaped(self): - assert _escape_mdv2("wow!") == "wow\\!" + def test_all_three(self): + assert _escape_html("a < b & c > d") == "a < b & c > d" - def test_mixed_text_and_specials(self): - result = _escape_mdv2("Hello (world)!") - assert result == "Hello \\(world\\)\\!" + def test_non_html_specials_unchanged(self): + # Only &, <, > are special in HTML — everything else passes through + result = _escape_html("Price is $5.00! (50% off)") + assert result == "Price is $5.00! (50% off)" # ========================================================================= @@ -95,14 +92,25 @@ def test_none_input(self, adapter): # content is falsy, returned as-is assert adapter.format_message(None) is None - def test_plain_text_specials_escaped(self, adapter): - result = adapter.format_message("Price is $5.00!") - assert "\\." in result - assert "\\!" in result + def test_plain_text_unchanged(self, adapter): + assert adapter.format_message("Hello world") == "Hello world" - def test_plain_text_no_markdown(self, adapter): - result = adapter.format_message("Hello world") - assert result == "Hello world" + def test_angle_brackets_escaped(self, adapter): + # Literal < and > in prose must become HTML entities + result = adapter.format_message("x < 5 and y > 3") + assert "<" in result + assert ">" in result + + def test_ampersand_escaped(self, adapter): + result = adapter.format_message("Tom & Jerry") + assert "&" in result + + def test_dots_and_exclamation_not_escaped(self, adapter): + # Dots and exclamation marks are not HTML-special + result = adapter.format_message("Price is $5.00!") + assert "\\." not in result + assert "\\!" not in result + assert "5.00!" in result # ========================================================================= @@ -111,41 +119,52 @@ def test_plain_text_no_markdown(self, adapter): class TestFormatMessageCodeBlocks: - def test_fenced_code_block_preserved(self, adapter): + def test_fenced_code_block_with_language(self, adapter): text = "Before\n```python\nprint('hello')\n```\nAfter" result = adapter.format_message(text) - # Code block contents must NOT be escaped - assert "```python\nprint('hello')\n```" in result - # But "After" should have no escaping needed (plain text) + # Language tag should appear as class attribute on + assert '' in result assert "After" in result - def test_inline_code_preserved(self, adapter): - text = "Use `my_var` here" + def test_fenced_code_block_no_language(self, adapter): + text = "```\nsome code\n```" result = adapter.format_message(text) - # Inline code content must NOT be escaped - assert "`my_var`" in result - # The surrounding text's underscore-free content should be fine - assert "Use" in result + # No language -> plainprint(\'hello\')without nested+ assert "some code" in result - def test_code_block_special_chars_not_escaped(self, adapter): + def test_inline_code(self, adapter): + result = adapter.format_message("Use `my_var` here") + # Inline code content must NOT be converted or double-escaped + assert "my_var" in result + + def test_code_block_html_entities_escaped(self, adapter): + """HTML-special chars inside code blocks must be entity-escaped.""" 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 + assert ">" in result + assert "" in result - def test_inline_code_special_chars_not_escaped(self, adapter): - text = "Run `rm -rf ./*` carefully" + def test_inline_code_html_entities_escaped(self, adapter): + """HTML-special chars inside inline code must be entity-escaped.""" + text = "Run `a < b & c` carefully" result = adapter.format_message(text) - assert "`rm -rf ./*`" in result + assert "a < b & c" in result def test_multiple_code_blocks(self, adapter): text = "```\nblock1\n```\ntext\n```\nblock2\n```" result = adapter.format_message(text) assert "block1" in result assert "block2" in result - # "text" between blocks should be present + # Text between blocks should be present assert "text" 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 + assert "" not in result.split("")[1].split("")[0] + # ========================================================================= # format_message - bold and italic @@ -155,29 +174,27 @@ def test_multiple_code_blocks(self, adapter): 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 "bold" in result + # Original ** markers should be gone assert "**" not in result def test_italic_converted(self, adapter): result = adapter.format_message("This is *italic* text") - # MarkdownV2 italic uses _ - assert "_italic_" in result + assert "italic" in result def test_bold_with_special_chars(self, adapter): - result = adapter.format_message("**hello.world!**") - # Content inside bold should be escaped - assert "*hello\\.world\\!*" in result + # Content inside bold should be HTML-escaped + result = adapter.format_message("**hello**") + assert "hello<world>" in result def test_italic_with_special_chars(self, adapter): - result = adapter.format_message("*hello.world*") - assert "_hello\\.world_" in result + result = adapter.format_message("*a & b*") + assert "a & b" in result def test_bold_and_italic_in_same_line(self, adapter): result = adapter.format_message("**bold** and *italic*") - assert "*bold*" in result - assert "_italic_" in result + assert "bold" in result + assert "italic" in result # ========================================================================= @@ -188,35 +205,31 @@ def test_bold_and_italic_in_same_line(self, adapter): class TestFormatMessageHeaders: def test_h1_converted_to_bold(self, adapter): result = adapter.format_message("# Title") - # Header becomes bold in MarkdownV2 - assert "*Title*" in result + # Header becomes bold in HTML + assert "Title" in result # Hash should be removed assert "#" not in result def test_h2_converted(self, adapter): result = adapter.format_message("## Subtitle") - assert "*Subtitle*" in result + assert "Subtitle" in result def test_header_with_inner_bold_stripped(self, adapter): - # Headers strip redundant **...** inside + """Headers strip redundant **...** inside to avoid double-wrapping.""" 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 + # Should be Important, not Important + assert "Important" in result + assert result.count("") == 1 def test_header_with_special_chars(self, adapter): result = adapter.format_message("# Hello (World)!") - assert "\\(" in result - assert "\\)" in result - assert "\\!" in result + assert "Hello (World)!" in result def test_multiline_headers(self, adapter): text = "# First\nSome text\n## Second" result = adapter.format_message(text) - assert "*First*" in result - assert "*Second*" in result + assert "First" in result + assert "Second" in result assert "Some text" in result @@ -228,34 +241,120 @@ def test_multiline_headers(self, adapter): class TestFormatMessageLinks: def test_markdown_link_converted(self, adapter): result = adapter.format_message("[Click here](https://example.com)") - assert "[Click here](https://example.com)" in result + assert 'Click here' in result 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 + # The & in display text should be HTML-escaped + result = adapter.format_message("[A & B](https://example.com)") + assert "A & B" in result + assert "Google' in result + assert "today." in result # ========================================================================= -# format_message - BUG: italic regex spans newlines +# format_message - strikethrough +# ========================================================================= + + +class TestFormatMessageStrikethrough: + def test_strikethrough_converted(self, adapter): + result = adapter.format_message("~~deleted~~") + assert " deleted" in result + + def test_strikethrough_with_special_chars(self, adapter): + result = adapter.format_message("~~a < b~~") + assert "a < b" in result + + +# ========================================================================= +# format_message - blockquotes +# ========================================================================= + + +class TestFormatMessageBlockquotes: + def test_single_line_blockquote(self, adapter): + result = adapter.format_message("> Hello world") + assert "Hello world" in result + + def test_multi_line_blockquote(self, adapter): + """Consecutive > lines should merge into a single.""" + text = "> Line one\n> Line two\n> Line three" + result = adapter.format_message(text) + assert "" in result + assert "" in result + assert "Line one\nLine two\nLine three" in result + assert result.count("") == 1 + + def test_blockquote_with_special_chars(self, adapter): + result = adapter.format_message("> a < b & c > d") + assert "" in result + assert "<" in result + assert "&" in result + + +# ========================================================================= +# format_message - horizontal rules +# ========================================================================= + + +class TestFormatMessageHorizontalRules: + def test_triple_dash(self, adapter): + # --- on its own line -> em-dash separator (no native HR in Telegram) + result = adapter.format_message("above\n---\nbelow") + assert "\u2014\u2014\u2014" in result + assert "---" not in result + + def test_triple_asterisk(self, adapter): + result = adapter.format_message("above\n***\nbelow") + assert "\u2014\u2014\u2014" in result + + def test_triple_underscore(self, adapter): + result = adapter.format_message("above\n___\nbelow") + assert "\u2014\u2014\u2014" in result + + +# ========================================================================= +# format_message - tables +# ========================================================================= + + +class TestFormatMessageTables: + def test_simple_table(self, adapter): + """Markdown tables should be wrapped infor monospace alignment.""" + text = "| A | B |\n|---|---|\n| 1 | 2 |" + result = adapter.format_message(text) + assert "" in result + assert "| A | B |" in result + assert "" in result + + def test_table_with_special_chars(self, adapter): + """HTML-special chars inside tables must be entity-escaped.""" + text = "| A & B | C |\n|---|---|\n|| y |" + result = adapter.format_message(text) + assert " " in result + assert "&" in result + assert "<x>" in result + + +# ========================================================================= +# format_message - italic must not span newlines # ========================================================================= class TestItalicNewlineBug: - r"""Italic regex ``\*([^*]+)\*`` matched across newlines, corrupting content. + r"""The italic regex uses [^*\n]+ to prevent matching across newlines. - This affects bullet lists using * markers and any text where * appears - at the end of one line and start of another. + Without this restriction, bullet lists using * markers and any text + where * appears at the end of one line and start of another would be + incorrectly wrapped as italic. """ def test_bullet_list_not_corrupted(self, adapter): @@ -266,37 +365,129 @@ def test_bullet_list_not_corrupted(self, adapter): 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 def test_asterisk_list_items_preserved(self, adapter): """Each * list item should remain as a separate line, not become italic.""" text = "* Alpha\n* Beta" result = adapter.format_message(text) - # Both items must be present in output assert "Alpha" in result assert "Beta" in result - # The text between first * and second * must NOT become italic lines = result.split("\n") assert len(lines) >= 2 def test_italic_does_not_span_lines(self, adapter): - """*text on\nmultiple lines* should NOT become italic.""" + """*text on\\nmultiple lines* should NOT become italic.""" text = "Start *across\nlines* end" result = adapter.format_message(text) - # Should NOT have underscore italic markers wrapping cross-line text - # If this fails, the italic regex is matching across newlines - assert "_across\nlines_" not in result + # Should NOT have wrapping cross-line text + assert "across\nlines" not in result def test_single_line_italic_still_works(self, adapter): """Normal single-line italic must still convert correctly.""" - text = "This is *italic* text" + result = adapter.format_message("This is *italic* text") + assert "italic" in result + + +# ========================================================================= +# format_message - lists (pass-through, no conversion needed) +# ========================================================================= + + +class TestFormatMessageLists: + def test_dash_list_preserved(self, adapter): + """Dash lists pass through as plain text — Telegram renders them fine.""" + text = "- Item one\n- Item two" + result = adapter.format_message(text) + assert "- Item one" in result + assert "- Item two" in result + + def test_numbered_list_preserved(self, adapter): + """Numbered lists pass through as plain text.""" + text = "1. First\n2. Second" + result = adapter.format_message(text) + assert "1. First" in result + assert "2. Second" in result + + +# ========================================================================= +# format_message - chunked digest content +# +# Long messages (e.g. news digests) are chunked before formatting. Each +# chunk must produce valid HTML independently, including chunk indicators +# like (1/2) that are appended by the base chunker. +# ========================================================================= + + +class TestChunkedDigestContent: + def test_digest_chunks_format_independently(self, adapter): + """A digest-style message that exceeds 4096 chars should produce + valid HTML in every chunk.""" + # Build a realistic digest that exceeds the chunk limit + items = [] + for i in range(30): + items.append( + f"- [Article title number {i} with enough length to be realistic]" + f"(https://www.example.com/news/longer-article-slug-{i}-detail-{i*11}0784.html)" + f" \u2014 Summary of article {i} with additional context and description." + ) + text = ( + "## Monday, 14.03.2026\n\n" + "**Security & Privacy**\n" + + "\n".join(items[:8]) + + "\n\n**Tech & Business**\n" + + "\n".join(items[8:16]) + + "\n\n**Science & Culture**\n" + + "\n".join(items[16:]) + + "\n\n*25 articles total.*" + ) + assert len(text) > 4096, "test input must exceed Telegram's limit" + + chunks = adapter.truncate_message( + text, adapter.MAX_MESSAGE_LENGTH - adapter.HTML_OVERHEAD_RESERVE + ) + assert len(chunks) >= 2 + + for i, chunk in enumerate(chunks): + formatted = adapter.format_message(chunk) + assert "\x00" not in formatted, f"chunk {i} leaked placeholders" + # Every chunk should contain valid HTML, not raw markdown artifacts + assert "\\" not in formatted, f"chunk {i} has backslash escapes" + + def test_digest_section_headers_and_links(self, adapter): + """Bold section headers followed by link list items — the typical + pattern in digest output — should produce clean HTML.""" + text = ( + "**Security & Privacy**\n" + "- [Update released for browser](https://example.com/news/update-1234.html)" + " \u2014 Vendor follows up after initial fix was insufficient.\n" + "- [Hidden fees result in fine](https://example.com/news/fees-5678.html)" + " \u2014 Company settles lawsuit.\n" + "\n**Tech & Business**\n" + "- [Cloud service turns 20](https://example.com/opinion/cloud-20-years.html)" + " \u2014 Industry standard and vendor lock-in." + ) result = adapter.format_message(text) - assert "_italic_" in result + assert "Security & Privacy" in result + assert "Tech & Business" in result + assert result.count("12 articles total." in result + assert result.count("" in result + assert "" in result def test_link_inside_code_not_converted(self, adapter): + """Link syntax inside inline code should not become an tag.""" text = "`[not a link](url)`" result = adapter.format_message(text) - assert "`[not a link](url)`" in result + assert "" in result + assert "Title" in result def test_multiple_bold_segments(self, adapter): result = adapter.format_message("**a** and **b** and **c**") - assert result.count("*") >= 6 # 3 bold pairs = 6 asterisks - - def test_special_chars_in_plain_text(self, adapter): - result = adapter.format_message("Price: $5.00 (50% off!)") - assert "\\." in result - assert "\\(" in result - assert "\\)" in result - assert "\\!" in result + assert result.count("") == 3 + assert result.count("") == 3 def test_empty_bold(self, adapter): """**** (empty bold) should not crash.""" @@ -342,7 +523,7 @@ def test_empty_bold(self, adapter): def test_empty_code_block(self, adapter): result = adapter.format_message("```\n```") - assert "```" in result + assert "" in result def test_placeholder_collision(self, adapter): """Many formatting elements should not cause placeholder collisions.""" @@ -363,32 +544,32 @@ def test_placeholder_collision(self, adapter): # ========================================================================= -# _strip_mdv2 — plaintext fallback +# _strip_html — plaintext fallback # ========================================================================= -class TestStripMdv2: - def test_removes_escape_backslashes(self): - assert _strip_mdv2(r"hello\.world\!") == "hello.world!" +class TestStripHtml: + def test_strips_bold_tags(self): + assert _strip_html("bold text") == "bold text" - def test_removes_bold_markers(self): - assert _strip_mdv2("*bold text*") == "bold text" + def test_strips_italic_tags(self): + assert _strip_html("italic") == "italic" - def test_removes_italic_markers(self): - assert _strip_mdv2("_italic text_") == "italic text" + def test_strips_pre_tags(self): + assert _strip_html("code") == "code" - def test_removes_both_bold_and_italic(self): - result = _strip_mdv2("*bold* and _italic_") - assert result == "bold and italic" + def test_strips_nested_tags(self): + assert _strip_html('') == "x" - def test_preserves_snake_case(self): - assert _strip_mdv2("my_variable_name") == "my_variable_name" - - def test_preserves_multi_underscore_identifier(self): - assert _strip_mdv2("some_func_call here") == "some_func_call here" + def test_unescapes_html_entities(self): + # Entity reversal must restore original characters + assert _strip_html("a < b & c > d") == "a < b & c > d" def test_plain_text_unchanged(self): - assert _strip_mdv2("plain text") == "plain text" + assert _strip_html("plain text") == "plain text" def test_empty_string(self): - assert _strip_mdv2("") == "" + assert _strip_html("") == "" + + def test_strips_link_tags(self): + assert _strip_html('link') == "link"x