@@ -231,6 +231,89 @@ def load_changelog(repo_root: Path, version: str) -> tuple[str | None, str | Non
231231 return persian , english
232232
233233
234+ def brief_changelog (text : str , max_total : int = 1500 ) -> str :
235+ """Compress a changelog body to top-level bullets only, with each bullet
236+ trimmed to a short readable headline.
237+
238+ Sub-bullets, prose explanations, contributor @-mentions, and embedded
239+ "by @user with full root cause + fix" prefatory phrases are stripped.
240+ Markdown link `[text](url)` becomes plain `text`, with the special case
241+ of `[#nnn](url)` → `#nnn` (issue/PR number stays readable without the
242+ visual clutter of the URL). The result still goes through
243+ `md_to_tg_html` for backtick → <code> conversion.
244+
245+ Why bullets-only: Telegram channel readers want "what shipped" in a
246+ glance, not the architectural detail that lives in the git log + the
247+ full `docs/changelog/v*.md` file. The full English text is still in
248+ the repo for archival.
249+
250+ `max_total` caps the assembled brief so the announcement stays well
251+ under Telegram's 4096-char sendMessage budget after header / footer
252+ chrome is added.
253+ """
254+ out : list [str ] = []
255+ total_len = 0
256+
257+ for raw in text .splitlines ():
258+ if not raw .startswith ("• " ):
259+ continue
260+ body = raw [2 :].strip ()
261+
262+ # Strip "by @user with full root cause + fix" / "from @user" /
263+ # "by @user". The "with ..." clause after "by @user" runs to the
264+ # next closing paren — greedy `[^)]*` is what consumes it
265+ # cleanly. Without the greedy form, the trailing "with full
266+ # root cause + fix" remained in the headline.
267+ body = re .sub (r" by @[\w-]+(?: with [^)]*)?" , "" , body )
268+ body = re .sub (r" from @[\w-]+" , "" , body )
269+
270+ # `(PR [#nnn](url))` → `(#nnn)` and bare `[#nnn](url)` → `#nnn`.
271+ # Done before generic `[text](url)` so the issue-number form
272+ # wins over the catch-all (which would expand the link text).
273+ body = re .sub (r"PR \[#(\d+)\]\([^)]+\)" , r"#\1" , body )
274+ body = re .sub (r"\[#(\d+)\]\([^)]+\)" , r"#\1" , body )
275+ body = re .sub (r"\[([^\]]+)\]\([^)]+\)" , r"\1" , body )
276+
277+ # Cut at the first natural sentence boundary that isn't too
278+ # early. ":" anchored to position ≥ 30 catches "Title: details"
279+ # without truncating short headers like "Tests:" / "API:" /
280+ # "Build:" that are themselves the headline. ". " catches the
281+ # rest. " — " (em-dash with spaces) is our explicit "headline
282+ # — body" form in the changelogs.
283+ candidates = []
284+ for sep , min_pos in ((":" , 30 ), (". " , 5 ), (" — " , 5 )):
285+ idx = body .find (sep )
286+ if idx >= min_pos and idx < 200 :
287+ candidates .append (idx )
288+ if candidates :
289+ body = body [: min (candidates )].rstrip ()
290+
291+ # Hard cap at 200 chars so a single sentence-less bullet
292+ # (e.g. comma-separated list) can't dominate the brief.
293+ if len (body ) > 200 :
294+ body = body [:197 ].rstrip () + "…"
295+
296+ # If our truncation left an unclosed `(`, strip from there. A
297+ # dangling `(` reads as a typo in the channel post; better to
298+ # drop the parenthesised aside than to show a half-open one.
299+ # Same for `[`. Counts compare: if open > close, find the last
300+ # offending char and trim back to the previous space.
301+ for open_ch , close_ch in (("(" , ")" ), ("[" , "]" )):
302+ if body .count (open_ch ) > body .count (close_ch ):
303+ last = body .rfind (open_ch )
304+ if last > 0 :
305+ body = body [:last ].rstrip ()
306+
307+ line = f"• { body } "
308+ # +1 for the line separator we'll insert when joining.
309+ if total_len + len (line ) + 1 > max_total :
310+ break
311+ out .append (line )
312+ total_len += len (line ) + 1
313+
314+ return "\n " .join (out )
315+
316+
234317def md_to_tg_html (md : str , max_len : int = TG_CHANGELOG_BUDGET ) -> str :
235318 """Convert a subset of Markdown to Telegram-flavoured HTML.
236319
@@ -479,40 +562,44 @@ def post_main_channel_pointer(
479562 hashtag : str ,
480563 channel_username_link : str = "" ,
481564 channel_invite_link : str = "" ,
482- persian_notes : str | None = None ,
565+ english_notes_brief : str | None = None ,
483566) -> bool :
484567 """Post a short cross-link to the main announcement channel pointing
485568 at the anchor post in the files channel. Replaces the previous
486569 behaviour of posting the universal APK + full changelog directly
487570 to the main channel — the main channel becomes a discovery surface
488571 while the files channel hosts the actual artifacts.
489572
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.
573+ When `english_notes_brief` is supplied (the brief-extracted English
574+ half of `docs/changelog/v{version}.md` via `brief_changelog`), it's
575+ rendered between the title and the files-channel link so subscribers
576+ see what's new without clicking through. Falls back to the bare
577+ pointer if notes aren't available.
578+
579+ English brief (not Persian full) is what we ship to TG: the audience
580+ is the worldwide channel, and short brief-tone bullets read cleanly
581+ in a chat client where Persian RTL prose mixed with `<code>` /
582+ `<b>` spans rendered awkwardly. The full Persian + full English
583+ changelog stays in `docs/changelog/v*.md` for archival.
495584
496585 Includes channel-join links (public username + invite hash) at the
497586 bottom so recipients who aren't yet members can subscribe before
498587 clicking through to the specific release post.
499588 """
500589 parts = [
501- f"<b>📦 mhrv-rs v{ html_escape (version )} منتشر شد </b>" ,
590+ f"<b>📦 mhrv-rs v{ html_escape (version )} released </b>" ,
502591 "" ,
503592 ]
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 ))
593+ if english_notes_brief :
594+ # Tighter budget than the files-channel announcement since the
595+ # cross-link has extra footer chrome (channel-join links).
596+ parts .append (md_to_tg_html (english_notes_brief , max_len = TG_CHANGELOG_BUDGET - 400 ))
509597 parts .append ("" )
510598 parts .extend ([
511- f"برای دانلود فایلها (Android، Windows، macOS، Linux و ...) "
512- f"به کانال فایلها مراجعه کنید:" ,
599+ f"Files (Android APKs, Windows, macOS, Linux, OpenWRT) on the files channel:" ,
513600 "" ,
514601 f"👉 <a href=\" { html_escape (files_channel_post_link )} \" >"
515- f"v{ html_escape (version )} — همه فایلها + SHA-256</a>" ,
602+ f"v{ html_escape (version )} — all files with SHA-256</a>" ,
516603 ])
517604 # Channel-join links. Two forms handle different states of the
518605 # files channel: the `t.me/<username>` form works for public
@@ -522,15 +609,15 @@ def post_main_channel_pointer(
522609 # is forgiving — recipients click whichever works for them.
523610 if channel_username_link or channel_invite_link :
524611 parts .append ("" )
525- parts .append ("لینک کانال :" )
612+ parts .append ("Channel :" )
526613 if channel_username_link :
527614 # Render as plain URL (not HTML <a>) so the text shows the
528615 # link itself — useful when users share the message via
529616 # screenshot or copy-paste outside Telegram, which would
530617 # strip the <a href> wrapper.
531618 parts .append (html_escape (channel_username_link ))
532619 if channel_invite_link :
533- parts .append (f"و یا : { html_escape (channel_invite_link )} " )
620+ parts .append (f"or : { html_escape (channel_invite_link )} " )
534621 parts .append ("" )
535622 parts .append (hashtag )
536623 text = "\n " .join (parts )
@@ -603,23 +690,25 @@ def main() -> int:
603690 # saying "new release, click here." Recipients land on this anchor
604691 # and scroll down to see all the platform-specific files.
605692 #
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 )
693+ # We pull the English half of `docs/changelog/v{version}.md`, run it
694+ # through `brief_changelog` to keep just the top-level bullets (no
695+ # sub-bullets, no contributor mentions, no embedded prose), and
696+ # inject that into the announcement. Brief English (not full Persian)
697+ # is the right tone for a Telegram channel post: subscribers want
698+ # "what shipped" in one glance; the full archival changelog stays in
699+ # the repo. Falls back to the bare skeleton if the changelog file
700+ # doesn't exist (e.g. an out-of-band re-publish for an old tag).
701+ _persian_notes , english_notes = load_changelog (repo_root_from_script (), args .version )
702+ english_brief = brief_changelog (english_notes ) if english_notes else None
613703 announce_lines = [
614- f"<b>📦 mhrv-rs { html_escape ('v' + args .version )} منتشر شد </b>" ,
704+ f"<b>📦 mhrv-rs { html_escape ('v' + args .version )} released </b>" ,
615705 "" ,
616706 ]
617- if persian_notes :
618- announce_lines .append (md_to_tg_html (persian_notes ))
707+ if english_brief :
708+ announce_lines .append (md_to_tg_html (english_brief ))
619709 announce_lines .append ("" )
620710 announce_lines .extend ([
621- "فایلها در ادامه به ترتیب پلتفرم ارسال میشن." ,
622- "هر فایل با SHA-256 (تایید اصالت) همراه هست." ,
711+ "Per-platform files follow with SHA-256 captions for verification." ,
623712 "" ,
624713 args .hashtag ,
625714 ])
@@ -689,7 +778,7 @@ def main() -> int:
689778 args .hashtag ,
690779 channel_username_link = username_link ,
691780 channel_invite_link = invite_link ,
692- persian_notes = persian_notes ,
781+ english_notes_brief = english_brief ,
693782 )
694783 if not ok :
695784 failures += 1
0 commit comments