|
19 | 19 | import argparse |
20 | 20 | import hashlib |
21 | 21 | import os |
| 22 | +import re |
22 | 23 | import sys |
23 | 24 | import time |
24 | 25 | import urllib.error |
|
27 | 28 | import json |
28 | 29 | from pathlib import Path |
29 | 30 |
|
| 31 | +# Telegram sendMessage caps at 4096 chars total. Leave headroom for |
| 32 | +# the announcement header / cross-link footer / hashtag — anything |
| 33 | +# above this gets truncated with a "see full notes on GitHub" tail. |
| 34 | +TG_CHANGELOG_BUDGET = 3500 |
| 35 | + |
30 | 36 | # Telegram Bot API uploads cap at 50 MB. Pick 45 MB for chunks so the |
31 | 37 | # multipart envelope + caption + Telegram's own overhead don't push us |
32 | 38 | # over. Bigger chunks (e.g. 49 MB) sometimes hit "Request Entity Too |
@@ -194,6 +200,135 @@ def html_escape(s: str) -> str: |
194 | 200 | return s.replace("&", "&").replace("<", "<").replace(">", ">") |
195 | 201 |
|
196 | 202 |
|
| 203 | +def load_changelog(repo_root: Path, version: str) -> tuple[str | None, str | None]: |
| 204 | + """Read `docs/changelog/v{version}.md` and split into (Persian, English). |
| 205 | +
|
| 206 | + The repo convention (see `docs/changelog/v1.1.0.md`) is: |
| 207 | + <!-- comment line --> |
| 208 | + Persian content... |
| 209 | + --- |
| 210 | + English content... |
| 211 | +
|
| 212 | + Returns (None, None) if the file doesn't exist (lets callers fall back |
| 213 | + to the bare "release dropped" announcement gracefully). Returns |
| 214 | + (persian, None) if there's no `---` separator (single-language file). |
| 215 | + """ |
| 216 | + path = repo_root / "docs" / "changelog" / f"v{version}.md" |
| 217 | + if not path.is_file(): |
| 218 | + return None, None |
| 219 | + text = path.read_text(encoding="utf-8") |
| 220 | + # Strip leading HTML comments (the standard `<!-- see docs/... -->` |
| 221 | + # header). Their content is for editors, not readers. |
| 222 | + text = re.sub(r"^\s*<!--.*?-->\s*", "", text, count=1, flags=re.DOTALL) |
| 223 | + # Split on the literal `---` line that separates Persian from English. |
| 224 | + # We require it to be on its own line so an inline `---` inside a code |
| 225 | + # block doesn't accidentally split the body. |
| 226 | + parts = re.split(r"\n\s*---\s*\n", text, maxsplit=1) |
| 227 | + persian = parts[0].strip() or None |
| 228 | + english = parts[1].strip() if len(parts) > 1 else None |
| 229 | + if english: |
| 230 | + english = english.strip() or None |
| 231 | + return persian, english |
| 232 | + |
| 233 | + |
| 234 | +def md_to_tg_html(md: str, max_len: int = TG_CHANGELOG_BUDGET) -> str: |
| 235 | + """Convert a subset of Markdown to Telegram-flavoured HTML. |
| 236 | +
|
| 237 | + Handles only the patterns that show up in our changelog files: |
| 238 | + - `**bold**` → `<b>bold</b>` |
| 239 | + - `[text](url)` → `<a href="url">text</a>` |
| 240 | + - `` `code` `` → `<code>code</code>` |
| 241 | + - `<!-- comment -->` → stripped |
| 242 | + - everything else → HTML-escaped, line breaks preserved |
| 243 | +
|
| 244 | + The order of operations matters because the input is going through |
| 245 | + HTML escape: we first carve out the markdown spans into placeholders |
| 246 | + that escape() can't touch, then escape the rest, then put the spans |
| 247 | + back as Telegram HTML. This is the same trick the Python `markdown` |
| 248 | + package uses for inline tokens — much simpler than a real parser |
| 249 | + when the input grammar is tiny. |
| 250 | +
|
| 251 | + The result is also truncated at `max_len` chars (Telegram's 4096-char |
| 252 | + sendMessage limit minus header/footer headroom). Truncation snaps to |
| 253 | + the previous newline so we never cut a markdown span in half. |
| 254 | + """ |
| 255 | + # 1. Strip HTML comments, including multi-line. |
| 256 | + md = re.sub(r"<!--.*?-->", "", md, flags=re.DOTALL).strip() |
| 257 | + |
| 258 | + # 2. Carve out the markdown spans into placeholder tokens. We pick a |
| 259 | + # NUL-delimited form because NUL is illegal in markdown source and |
| 260 | + # in Telegram messages — safe placeholder. |
| 261 | + spans: list[str] = [] |
| 262 | + |
| 263 | + def stash(html: str) -> str: |
| 264 | + spans.append(html) |
| 265 | + return f"\x00{len(spans) - 1}\x00" |
| 266 | + |
| 267 | + # Inline code first — backticks are exclusive of the other patterns. |
| 268 | + md = re.sub( |
| 269 | + r"`([^`\n]+)`", |
| 270 | + lambda m: stash(f"<code>{html_escape(m.group(1))}</code>"), |
| 271 | + md, |
| 272 | + ) |
| 273 | + # Markdown links `[text](url)` — link text gets HTML-escaped, URL is |
| 274 | + # passed through but quotes inside it would break the attribute, so |
| 275 | + # we escape `"` only there. |
| 276 | + md = re.sub( |
| 277 | + r"\[([^\]]+)\]\(([^)]+)\)", |
| 278 | + lambda m: stash( |
| 279 | + f'<a href="{m.group(2).replace(chr(34), """)}">' |
| 280 | + f"{html_escape(m.group(1))}</a>" |
| 281 | + ), |
| 282 | + md, |
| 283 | + ) |
| 284 | + # Bold `**text**`. Done after links so a `**[text](url)**` pattern |
| 285 | + # still works (the link is already a placeholder by now). |
| 286 | + md = re.sub( |
| 287 | + r"\*\*([^*\n]+)\*\*", |
| 288 | + lambda m: stash(f"<b>{html_escape(m.group(1))}</b>"), |
| 289 | + md, |
| 290 | + ) |
| 291 | + |
| 292 | + # 3. HTML-escape everything that wasn't a span. Placeholders survive |
| 293 | + # because they contain only NUL and digits, which the escape pass |
| 294 | + # leaves alone. |
| 295 | + md = html_escape(md) |
| 296 | + |
| 297 | + # 4. Restore the placeholders. We loop because a placeholder's |
| 298 | + # expansion can itself contain placeholders — e.g. a markdown |
| 299 | + # link `[`code`](url)` stashes the inline code first, then the |
| 300 | + # link captures the code's `\x00N\x00` token as its link text. |
| 301 | + # A single pass would leave that inner token un-restored. Bound |
| 302 | + # the loop to len(spans)+1 so a malformed input can't run away. |
| 303 | + for _ in range(len(spans) + 1): |
| 304 | + new = re.sub( |
| 305 | + r"\x00(\d+)\x00", |
| 306 | + lambda m: spans[int(m.group(1))], |
| 307 | + md, |
| 308 | + ) |
| 309 | + if new == md: |
| 310 | + break |
| 311 | + md = new |
| 312 | + |
| 313 | + # 5. Truncate to fit Telegram's sendMessage cap. Snap to a newline |
| 314 | + # boundary so a code/link span isn't cut in half. The trailing |
| 315 | + # "..." line tells the reader to go to GitHub for the full notes. |
| 316 | + if len(md) > max_len: |
| 317 | + cut = md.rfind("\n", 0, max_len) |
| 318 | + if cut < max_len // 2: |
| 319 | + cut = max_len # very long single line — chop hard |
| 320 | + md = md[:cut].rstrip() + "\n…\n<i>(see full notes on GitHub)</i>" |
| 321 | + return md |
| 322 | + |
| 323 | + |
| 324 | +def repo_root_from_script() -> Path: |
| 325 | + """Find the repo root from this script's location: `<root>/.github/ |
| 326 | + scripts/telegram_publish_files.py` → `<root>`. Used by `load_changelog` |
| 327 | + so callers don't have to pass it in (and so the script Just Works |
| 328 | + when run from `cwd != repo root`).""" |
| 329 | + return Path(__file__).resolve().parent.parent.parent |
| 330 | + |
| 331 | + |
197 | 332 | def sha256_hex(path: Path) -> str: |
198 | 333 | """Stream-hash the file in 1 MiB chunks. Avoids loading 40+ MB APKs |
199 | 334 | into RAM twice (once for hashing, once for upload).""" |
@@ -344,26 +479,41 @@ def post_main_channel_pointer( |
344 | 479 | hashtag: str, |
345 | 480 | channel_username_link: str = "", |
346 | 481 | channel_invite_link: str = "", |
| 482 | + persian_notes: str | None = None, |
347 | 483 | ) -> bool: |
348 | 484 | """Post a short cross-link to the main announcement channel pointing |
349 | 485 | at the anchor post in the files channel. Replaces the previous |
350 | 486 | behaviour of posting the universal APK + full changelog directly |
351 | 487 | to the main channel — the main channel becomes a discovery surface |
352 | 488 | while the files channel hosts the actual artifacts. |
353 | 489 |
|
| 490 | + When `persian_notes` is supplied (the Persian half of the matching |
| 491 | + `docs/changelog/v{version}.md`), it's rendered between the title |
| 492 | + and the files-channel link so subscribers see what's actually new |
| 493 | + without needing to click through. Falls back to the bare pointer |
| 494 | + if notes aren't available. |
| 495 | +
|
354 | 496 | Includes channel-join links (public username + invite hash) at the |
355 | 497 | bottom so recipients who aren't yet members can subscribe before |
356 | 498 | clicking through to the specific release post. |
357 | 499 | """ |
358 | 500 | parts = [ |
359 | 501 | f"<b>📦 mhrv-rs v{html_escape(version)} منتشر شد</b>", |
360 | 502 | "", |
| 503 | + ] |
| 504 | + if persian_notes: |
| 505 | + # Use a slightly tighter budget here since the cross-link has |
| 506 | + # extra footer chrome (channel-join links) the files-channel |
| 507 | + # announcement doesn't. |
| 508 | + parts.append(md_to_tg_html(persian_notes, max_len=TG_CHANGELOG_BUDGET - 400)) |
| 509 | + parts.append("") |
| 510 | + parts.extend([ |
361 | 511 | f"برای دانلود فایلها (Android، Windows، macOS، Linux و ...) " |
362 | 512 | f"به کانال فایلها مراجعه کنید:", |
363 | 513 | "", |
364 | 514 | f"👉 <a href=\"{html_escape(files_channel_post_link)}\">" |
365 | 515 | f"v{html_escape(version)} — همه فایلها + SHA-256</a>", |
366 | | - ] |
| 516 | + ]) |
367 | 517 | # Channel-join links. Two forms handle different states of the |
368 | 518 | # files channel: the `t.me/<username>` form works for public |
369 | 519 | # channels and is the prettier link; the `t.me/+<hash>` invite |
@@ -452,12 +602,28 @@ def main() -> int: |
452 | 602 | # main channel doesn't carry files anymore, just a single message |
453 | 603 | # saying "new release, click here." Recipients land on this anchor |
454 | 604 | # and scroll down to see all the platform-specific files. |
455 | | - announce = ( |
456 | | - f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>\n" |
457 | | - f"\nفایلها در ادامه به ترتیب پلتفرم ارسال میشن.\n" |
458 | | - f"هر فایل با SHA-256 (تایید اصالت) همراه هست.\n" |
459 | | - f"\n{args.hashtag}" |
460 | | - ) |
| 605 | + # |
| 606 | + # We pull the Persian half of `docs/changelog/v{version}.md` if it |
| 607 | + # exists and inject it into the announcement, so the channel post |
| 608 | + # actually tells subscribers what changed instead of just "new |
| 609 | + # release dropped." Falls back to the old skeleton when the file |
| 610 | + # isn't there (e.g. an out-of-band re-publish for an old tag whose |
| 611 | + # changelog file was never landed). |
| 612 | + persian_notes, _english_notes = load_changelog(repo_root_from_script(), args.version) |
| 613 | + announce_lines = [ |
| 614 | + f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>", |
| 615 | + "", |
| 616 | + ] |
| 617 | + if persian_notes: |
| 618 | + announce_lines.append(md_to_tg_html(persian_notes)) |
| 619 | + announce_lines.append("") |
| 620 | + announce_lines.extend([ |
| 621 | + "فایلها در ادامه به ترتیب پلتفرم ارسال میشن.", |
| 622 | + "هر فایل با SHA-256 (تایید اصالت) همراه هست.", |
| 623 | + "", |
| 624 | + args.hashtag, |
| 625 | + ]) |
| 626 | + announce = "\n".join(announce_lines) |
461 | 627 | announce_msg_id: int | None = None |
462 | 628 | try: |
463 | 629 | url = f"https://api.telegram.org/bot{bot_token}/sendMessage" |
@@ -523,6 +689,7 @@ def main() -> int: |
523 | 689 | args.hashtag, |
524 | 690 | channel_username_link=username_link, |
525 | 691 | channel_invite_link=invite_link, |
| 692 | + persian_notes=persian_notes, |
526 | 693 | ) |
527 | 694 | if not ok: |
528 | 695 | failures += 1 |
|
0 commit comments