Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 67 additions & 6 deletions scripts/notify_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,21 +241,82 @@ 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"</?([A-Za-z][A-Za-z0-9-]*)", tag)
return m.group(1) if m else None


def _chunk_telegram_html(html, limit):
"""Split rendered Telegram HTML without breaking tags or the size limit."""
# Reserve room for the [i/N] footer added by telegram(). The previous
# Markdown-first chunker could not account for HTML escaping/tag overhead.
budget = max(1, limit - 32)
chunks, current, stack = [], "", []

def closing():
return "".join(f"</{name}>" 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("</"):
if stack and stack[-1][0] == name:
stack.pop()
else:
stack.append((name, token))

if current:
chunks.append(current + closing())
return chunks


# ---- per-channel payload builders -----------------------------------------

def telegram(text, title, severity, limit=3900):
meta = SEVERITY.get(severity, SEVERITY[DEFAULT_SEVERITY])
body = text
if title:
body = "**%s %s**\n\n%s" % (meta["emoji"], title, body)
# Chunk the Markdown first (fence-safe, tested), leaving headroom for the
# HTML growth (<b></b>, <a href=…>) 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


Expand Down
12 changes: 12 additions & 0 deletions scripts/tests/test_notify_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("<a "), rendered.count("</a>"))

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)
Expand Down