diff --git a/scripts/notify_format.py b/scripts/notify_format.py index 58665ddaba2..b8d4be0bdf0 100755 --- a/scripts/notify_format.py +++ b/scripts/notify_format.py @@ -241,6 +241,65 @@ def md_to_telegram_html(text): return "\n".join(out) +_HTML_TAG = re.compile(r"(<\/?[A-Za-z][^>]*>)") +_HTML_ENTITY = re.compile(r"&(?:amp|lt|gt|quot);|.", re.DOTALL) + + +def _html_tag_name(tag): + m = re.match(r"" for name, _ in reversed(stack)) + + def reopening(): + return "".join(tag for _, tag in stack) + + def flush(): + nonlocal current + if current: + chunks.append(current + closing()) + current = reopening() + + def add_text(text): + nonlocal current + for unit in _HTML_ENTITY.findall(text): + if len(current) + len(unit) + len(closing()) > budget: + flush() + current += unit + + for token in _HTML_TAG.split(html): + if not token: + continue + if not token.startswith("<"): + add_text(token) + continue + + if len(current) + len(token) + len(closing()) > budget: + flush() + current += token + name = _html_tag_name(token) + if not name or token.endswith("/>"): + continue + if token.startswith(", ) that md_to_telegram_html adds per chunk. - md_chunks = chunk(body, min(limit, 3400)) - n = len(md_chunks) + # Render first, then split the HTML while preserving balanced tags. The + # Markdown-first chunker cannot know how much escaping and tag markup will + # expand a chunk, so it can emit Telegram payloads over the API limit. + html = md_to_telegram_html(body) + html_chunks = _chunk_telegram_html(html, limit) + n = len(html_chunks) out = [] - for i, c in enumerate(md_chunks): + for i, c in enumerate(html_chunks): suffix = f"\n\n[{i + 1}/{n}]" if n > 1 else "" - out.append(md_to_telegram_html(c) + suffix) + out.append(c + suffix) return out # list[str] of Telegram HTML diff --git a/scripts/tests/test_notify_format.py b/scripts/tests/test_notify_format.py index a9428f5294b..0afc52e2225 100755 --- a/scripts/tests/test_notify_format.py +++ b/scripts/tests/test_notify_format.py @@ -113,6 +113,18 @@ def test_realistic_body_is_valid_html(self): class TestChannels(unittest.TestCase): + def test_telegram_rendered_chunks_stay_within_limit_after_html_expansion(self): + # A Markdown-first split can fit 3,400 source characters while the + # rendered anchors expand the Telegram payload past its 3,900 limit. + body = " ".join( + f"[x](https://example.com/path/{i})" for i in range(1, 301) + ) + chunks = nf.telegram(body, title="T", severity="info", limit=3900) + self.assertGreater(len(chunks), 1) + for rendered in chunks: + self.assertLessEqual(len(rendered), 3900) + self.assertEqual(rendered.count("")) + def test_telegram_adds_index_suffix_when_split(self): chunks = nf.telegram("p\n\n" + "x" * 9000, title="", severity="info", limit=3900) self.assertGreater(len(chunks), 1)