Skip to content

Commit e9ce03e

Browse files
therealalephclaude
andcommitted
ci: post macOS/Linux/Windows/Android binaries as Telegram media group
The Telegram release notifier used to post just the universal APK with a single-document caption. This change ships the per-platform binaries for macOS (amd64+arm64 CLI), Linux (amd64+arm64 CLI), Windows (amd64 UI), and Android (universal APK) as a single Telegram media group with one caption listing every filename + SHA-256. Workflow side (.github/workflows/release.yml): - The telegram job now downloads ALL artifacts (was: APK only). - New `Prepare files for Telegram media group` step extracts the raw binaries out of each per-platform .tar.gz / .zip (no archive wrappers in the channel) and renames them with version suffixes (mhrv-rs-linux-amd64-v1.7.2, mhrv-rs-windows-amd64-ui-v1.7.2.exe, etc.). Per-platform extraction is best-effort: a missing artifact emits a `::warning::` and skips that platform rather than failing the whole post. - The post step builds a `--files <path>` arg list from tg-files/, sorted for deterministic order across runs, and invokes the notifier without --with-changelog (the script auto-replies with changelog whenever --files is used). Script side (.github/scripts/telegram_release_notify.py): - New --files arg (repeatable). 2..=10 files → sendMediaGroup; 1 file → sendDocument with the same caption shape; 0 → error. Telegram's sendMediaGroup rejects single-item groups, so the 1-file fallback isn't optional. - New build_media_group_caption() composes title + per-file filename+SHA list + repo/release URLs. Fits ~860 chars for a 6-file release; fallback to filename-only-list if a future swell pushes past Telegram's 1024-char caption cap. - send_media_group() handles the multipart/form-data shape with each file referenced as `attach://fileN` from the media JSON. Caption is attached to file 0 only (Telegram clients render per-item captions inconsistently for media groups; first-item-only is the safe pattern). - Legacy --apk path kept for any caller that hasn't migrated; either --apk or --files must be present (validated at startup). - _content_type_for() picks application/vnd.android.package-archive for .apk and application/octet-stream for everything else, so Telegram clients label the APK with the Android icon and label desktop binaries by filename without a misleading icon. Behavioural change for users: - The Telegram channel now sees one grouped post per release with all primary platform binaries inline, instead of just the APK. macOS users wanting the gatekeeper-friendly .app.zip still grab it from the GitHub Releases page; the Telegram drop is for the "give me the binary, I'll run it" path. - The Persian/English changelog reply that used to be opt-in (via TELEGRAM_INCLUDE_CHANGELOG=true) is now automatic in the --files path because the per-file SHA list eats the caption budget that previously held the FA brief-note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ae948f4 commit e9ce03e

2 files changed

Lines changed: 428 additions & 62 deletions

File tree

.github/scripts/telegram_release_notify.py

Lines changed: 277 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,81 @@ def sha256_of(path: str) -> str:
202202
return h.hexdigest()
203203

204204

205+
def build_media_group_caption(files: list, version: str, repo: str) -> str:
206+
"""Build the single shared caption for a media-group post.
207+
208+
Caption shape (each file is one filename + one SHA line):
209+
210+
<b>mhrv-rs v1.7.1</b>
211+
212+
<b>mhrv-rs-linux-amd64-v1.7.1</b>
213+
<code>{sha256}</code>
214+
215+
<b>mhrv-rs-windows-amd64-v1.7.1.exe</b>
216+
<code>{sha256}</code>
217+
218+
...
219+
220+
مخزن گیتهاب + مطالعه راهنمای کامل فارسی:
221+
https://github.com/{repo}
222+
223+
لینک به این نسخه:
224+
https://github.com/{repo}/releases/tag/v{version}
225+
226+
Telegram caption hard-cap is 1024 chars. A typical 6-file release
227+
fits comfortably (~860 chars); the budget check at the bottom is a
228+
safety net for edge cases (longer filename suffixes, extra files).
229+
230+
The release-note `<blockquote>` block that the single-document path
231+
renders does NOT belong here — with N files in the group the SHA
232+
list eats the caption budget, and the release-note bullets move to
233+
the reply-threaded changelog message instead (sent unconditionally
234+
when sending a media group, since there's nowhere else for them).
235+
"""
236+
lines: list = [f"<b>mhrv-rs v{version}</b>", ""]
237+
for path in files:
238+
name = os.path.basename(path)
239+
sha = sha256_of(path)
240+
lines.append(f"<b>{name}</b>")
241+
lines.append(f"<code>{sha}</code>")
242+
lines.append("")
243+
lines.extend([
244+
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
245+
f"https://github.com/{repo}",
246+
"",
247+
"لینک به این نسخه:",
248+
f"https://github.com/{repo}/releases/tag/v{version}",
249+
])
250+
caption = "\n".join(lines)
251+
if len(caption) > 1024:
252+
# Truncate from the SHA list — header + footer URLs must stay.
253+
# In practice we never hit this with the current 4-platform
254+
# release; this branch is a guard for "what if we add 5 more
255+
# ABIs later" so the caller doesn't silently fail Telegram's
256+
# cap rejection. Falling back to "list filenames only, drop
257+
# SHAs" keeps the post useful while flagging the issue in CI.
258+
print(
259+
f"::warning::caption {len(caption)} chars > 1024; "
260+
f"falling back to filename-only list",
261+
file=sys.stderr,
262+
)
263+
compact: list = [f"<b>mhrv-rs v{version}</b>", ""]
264+
for path in files:
265+
compact.append(f"• <code>{os.path.basename(path)}</code>")
266+
compact.extend([
267+
"",
268+
"(SHA-256 hashes truncated; see GitHub release page.)",
269+
"",
270+
"مخزن گیتهاب:",
271+
f"https://github.com/{repo}",
272+
"",
273+
"این نسخه:",
274+
f"https://github.com/{repo}/releases/tag/v{version}",
275+
])
276+
caption = "\n".join(compact)
277+
return caption
278+
279+
205280
def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict:
206281
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
207282
conn = http.client.HTTPSConnection(
@@ -224,10 +299,30 @@ def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> di
224299
return data["result"]
225300

226301

227-
def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
228-
"""Upload the APK file with a short HTML caption. Returns message_id."""
302+
def _content_type_for(path: str) -> str:
303+
"""Pick a sensible Content-Type so Telegram clients label the file
304+
with the right icon/extension hint. APKs get the Android-specific
305+
MIME so the channel preview shows the Android package icon; raw
306+
desktop binaries (Mach-O / ELF / PE) and tarballs fall through to
307+
octet-stream — Telegram still displays them with the user-supplied
308+
filename and downloads correctly.
309+
"""
310+
ext = os.path.splitext(path)[1].lower()
311+
if ext == ".apk":
312+
return "application/vnd.android.package-archive"
313+
return "application/octet-stream"
314+
315+
316+
def send_document(token: str, chat_id: str, file_path: str, caption: str) -> int:
317+
"""Upload a single file with a short HTML caption. Returns message_id.
318+
319+
Used for single-file releases (e.g. APK-only) and as the fallback
320+
when --files is passed exactly one path. Multi-file releases go
321+
through send_media_group instead — Telegram's sendDocument can only
322+
upload one file per call.
323+
"""
229324
boundary = "----" + uuid.uuid4().hex
230-
with open(apk_path, "rb") as f:
325+
with open(file_path, "rb") as f:
231326
file_bytes = f.read()
232327

233328
def text_field(name: str, value: str) -> bytes:
@@ -237,21 +332,24 @@ def text_field(name: str, value: str) -> bytes:
237332
f"{value}\r\n"
238333
).encode("utf-8")
239334

240-
def file_field(name: str, filename: str, content: bytes) -> bytes:
335+
def file_field(name: str, filename: str, content: bytes, content_type: str) -> bytes:
241336
head = (
242337
f"--{boundary}\r\n"
243338
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
244-
# Proper MIME type — makes the Telegram client show the APK
245-
# with the Android package icon and honour its size/name.
246-
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
339+
f"Content-Type: {content_type}\r\n\r\n"
247340
).encode("utf-8")
248341
return head + content + b"\r\n"
249342

250343
body = (
251344
text_field("chat_id", chat_id)
252345
+ text_field("caption", caption)
253346
+ text_field("parse_mode", "HTML")
254-
+ file_field("document", os.path.basename(apk_path), file_bytes)
347+
+ file_field(
348+
"document",
349+
os.path.basename(file_path),
350+
file_bytes,
351+
_content_type_for(file_path),
352+
)
255353
+ f"--{boundary}--\r\n".encode("utf-8")
256354
)
257355

@@ -264,6 +362,86 @@ def file_field(name: str, filename: str, content: bytes) -> bytes:
264362
return int(result["message_id"])
265363

266364

365+
def send_media_group(
366+
token: str, chat_id: str, file_paths: list, caption: str
367+
) -> int:
368+
"""Upload 2–10 files as a single Telegram media group. Returns the
369+
message_id of the first message in the group (which is what any
370+
threaded reply should reference).
371+
372+
Telegram quirks:
373+
- `sendMediaGroup` accepts 2..=10 items. Zero/one is rejected;
374+
eleven+ is rejected. Caller must pre-check.
375+
- The `caption` field on `InputMediaDocument` is shown only when
376+
attached to the FIRST item in the group — Telegram clients
377+
render that caption above the document stack. Captions on
378+
later items in the group are silently dropped by some clients.
379+
We attach the caption to file 0 and leave the rest captionless.
380+
- `media` parameter is a JSON-encoded array; each item references
381+
its file via `attach://<form-data-name>`. We use `file0`,
382+
`file1`, ... for clarity in case Telegram ever surfaces the
383+
multipart name in error responses.
384+
- The total bytes of all files in a single media group must fit
385+
in Telegram's per-request limit (50 MB for bot uploads). For
386+
our typical release (~6 files × ~5–15 MB each) we're well
387+
under, but a future Android APK swell could hit this — caller
388+
should split into multiple groups in that case.
389+
"""
390+
if len(file_paths) < 2 or len(file_paths) > 10:
391+
raise SystemExit(
392+
f"send_media_group: need 2..=10 files, got {len(file_paths)}"
393+
)
394+
395+
boundary = "----" + uuid.uuid4().hex
396+
397+
media_array = []
398+
for i, path in enumerate(file_paths):
399+
item = {"type": "document", "media": f"attach://file{i}"}
400+
if i == 0:
401+
# Caption on the first item only — see docstring.
402+
item["caption"] = caption
403+
item["parse_mode"] = "HTML"
404+
media_array.append(item)
405+
406+
parts: list = []
407+
408+
def text_field(name: str, value: str) -> bytes:
409+
return (
410+
f"--{boundary}\r\n"
411+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
412+
f"{value}\r\n"
413+
).encode("utf-8")
414+
415+
parts.append(text_field("chat_id", chat_id))
416+
parts.append(text_field("media", json.dumps(media_array)))
417+
418+
for i, path in enumerate(file_paths):
419+
with open(path, "rb") as f:
420+
content = f.read()
421+
head = (
422+
f"--{boundary}\r\n"
423+
f'Content-Disposition: form-data; name="file{i}"; '
424+
f'filename="{os.path.basename(path)}"\r\n'
425+
f"Content-Type: {_content_type_for(path)}\r\n\r\n"
426+
).encode("utf-8")
427+
parts.append(head + content + b"\r\n")
428+
429+
parts.append(f"--{boundary}--\r\n".encode("utf-8"))
430+
body = b"".join(parts)
431+
432+
# sendMediaGroup returns an array of Message objects (one per item);
433+
# caller reply-threading targets the first message.
434+
result = tg_request(
435+
"sendMediaGroup",
436+
token,
437+
body=body,
438+
content_type=f"multipart/form-data; boundary={boundary}",
439+
)
440+
if not isinstance(result, list) or not result:
441+
raise SystemExit(f"sendMediaGroup: unexpected response shape: {result!r}")
442+
return int(result[0]["message_id"])
443+
444+
267445
def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
268446
"""Post a text message as a reply to the APK message."""
269447
from urllib.parse import urlencode
@@ -286,27 +464,51 @@ def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
286464

287465
def main() -> int:
288466
ap = argparse.ArgumentParser()
289-
ap.add_argument("--apk", required=True)
467+
# Two ways to specify what to send:
468+
# --files <path> [--files <path> ...] (preferred, multi-platform)
469+
# Sends the files as a single Telegram media group with one
470+
# caption listing each filename + SHA-256. The follow-up changelog
471+
# reply is automatic for media-group posts (the FA bullet block
472+
# would normally live in the caption, but the per-file SHA list
473+
# eats that budget — see build_media_group_caption docstring).
474+
# --apk <path> (legacy, single file)
475+
# Sends one document with the original caption layout (title +
476+
# single SHA + brief FA note + two link rows). Reply with
477+
# changelog is gated on --with-changelog as before.
478+
# Exactly one of the two must be present; if --files is given multiple
479+
# times we use the media-group path even if --apk is also given.
480+
ap.add_argument("--apk", required=False,
481+
help="Single file to send via sendDocument (legacy). "
482+
"Prefer --files for new releases.")
483+
ap.add_argument("--files", action="append", default=[],
484+
help="Path to a release file. Pass once per file; "
485+
"2..=10 files are sent as a Telegram media group "
486+
"(one caption listing all filenames + SHA-256).")
290487
ap.add_argument("--version", required=True)
291488
ap.add_argument("--repo", required=True)
292489
ap.add_argument("--changelog", required=True,
293-
help="Path to docs/changelog/vX.Y.Z.md; only read when --with-changelog is passed.")
294-
# Default: just the APK + short caption (title + SHA-256 + repo URL +
295-
# release URL). The per-release Persian/English blockquote reply is
296-
# opt-in via `--with-changelog` so routine releases don't flood the
297-
# channel with bullet-point bodies. To re-enable for a specific tag:
298-
# set the repo variable TELEGRAM_INCLUDE_CHANGELOG=true before pushing
299-
# the tag (the workflow converts that into --with-changelog).
490+
help="Path to docs/changelog/vX.Y.Z.md; read for the "
491+
"FA brief-note in the legacy caption (--apk path) "
492+
"and for the reply-threaded changelog message.")
493+
# Default for --apk path: just the APK + short caption.
494+
# For --files path: the FA+EN reply is automatic since the caption is
495+
# full of SHA hashes; toggle is ignored in that case.
300496
ap.add_argument("--with-changelog", action="store_true",
301-
help="Include the Persian+English changelog as a reply-threaded message.")
497+
help="(--apk path only) Include the Persian+English "
498+
"changelog as a reply-threaded message. Ignored "
499+
"with --files: media group always replies with "
500+
"the changelog because the per-file SHA list "
501+
"leaves no caption room for the FA brief-note.")
302502
# Dry-run lets you verify the rendered caption locally without hitting
303-
# Telegram. Useful when changing the brief-release-note budget /
304-
# truncation logic — print, eyeball, push.
503+
# Telegram. Useful when changing caption layout — print, eyeball, push.
305504
ap.add_argument("--dry-run", action="store_true",
306505
help="Render the caption and print it instead of posting. "
307506
"Skips token/chat_id checks.")
308507
args = ap.parse_args()
309508

509+
if not args.files and not args.apk:
510+
ap.error("either --apk or --files is required")
511+
310512
if not args.dry_run:
311513
token = os.environ.get("BOT_TOKEN", "")
312514
chat_id = os.environ.get("CHAT_ID", "")
@@ -318,24 +520,64 @@ def main() -> int:
318520
chat_id = ""
319521

320522
ver = args.version
523+
524+
# ------------------------------------------------------------------
525+
# Multi-file path (media group)
526+
# ------------------------------------------------------------------
527+
if args.files:
528+
files = list(args.files)
529+
if len(files) == 1:
530+
# Telegram's sendMediaGroup rejects single-item groups, so
531+
# one-file --files calls fall through to sendDocument with
532+
# the same multi-file caption shape (still has the SHA list
533+
# below the title). This makes --files a clean superset of
534+
# --apk semantically: callers can pass 1..=10 files without
535+
# branching on platform-specific build outputs.
536+
caption = build_media_group_caption(files, ver, args.repo)
537+
if args.dry_run:
538+
print(f"--- DRY RUN: single-file caption ({len(caption)} chars) ---")
539+
print(caption)
540+
return 0
541+
mid = send_document(token, chat_id, files[0], caption)
542+
print(f"sendDocument OK, message_id={mid}")
543+
else:
544+
caption = build_media_group_caption(files, ver, args.repo)
545+
if args.dry_run:
546+
print(f"--- DRY RUN: media-group caption ({len(caption)} chars) ---")
547+
print(caption)
548+
print(f"--- {len(files)} files would be uploaded ---")
549+
for f in files:
550+
print(f" {os.path.basename(f)}")
551+
return 0
552+
mid = send_media_group(token, chat_id, files, caption)
553+
print(f"sendMediaGroup OK ({len(files)} files), first message_id={mid}")
554+
555+
# Always reply with the changelog when sending a media group —
556+
# the per-file SHA list pushed the FA bullet headlines out of
557+
# the caption, so the reply is the only place they fit.
558+
# Single-file --files calls also reply, which matches the
559+
# multi-file behaviour and avoids surprising users who switch
560+
# back and forth between 1-file and N-file releases.
561+
fa, en = parse_changelog(args.changelog)
562+
if not fa and not en:
563+
print(f"No changelog at {args.changelog}, skipping reply.")
564+
return 0
565+
reply_parts: list = []
566+
if fa:
567+
reply_parts.append(f"<blockquote>{fa}</blockquote>")
568+
if en:
569+
reply_parts.append(f"<blockquote>{en}</blockquote>")
570+
send_reply(token, chat_id, "\n\n".join(reply_parts), mid)
571+
print("Reply OK")
572+
return 0
573+
574+
# ------------------------------------------------------------------
575+
# Single-file path (legacy --apk, kept for any caller that hasn't
576+
# migrated to --files yet).
577+
# ------------------------------------------------------------------
321578
sha = sha256_of(args.apk)
322-
# Brief Persian release-note above the links. Pulled from the FA
323-
# half of `docs/changelog/v<ver>.md` so each release auto-includes
324-
# what's new without manual edits to this script. Truncated to fit
325-
# Telegram's 1024-char caption budget alongside title + SHA + the
326-
# two-link footer.
327579
fa_note = build_caption_release_note(args.changelog)
328580

329-
# Caption structure requested by the repo owner:
330-
# 1. Title + SHA-256 (as before)
331-
# 2. Brief Persian "what's new" note (extracted from changelog)
332-
# 3. Persian preamble labelling the repo link as
333-
# "GitHub repo + full Persian guide"
334-
# 4. Repo URL
335-
# 5. Persian preamble labelling the release link as
336-
# "this version's release — desktop/router builds live here"
337-
# 6. Release URL
338-
# Keeps total well under Telegram's 1024-char caption limit.
339581
caption_parts = [
340582
f"<b>mhrv-rs Android v{ver}</b>",
341583
"",
@@ -376,7 +618,7 @@ def main() -> int:
376618
print(f"No changelog at {args.changelog}, skipping reply.")
377619
return 0
378620

379-
parts = []
621+
parts: list = []
380622
if fa:
381623
parts.append(f"<blockquote>{fa}</blockquote>")
382624
if en:

0 commit comments

Comments
 (0)