@@ -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+
205280def 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+
267445def 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
287465def 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