|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Post each release artifact individually to a Telegram channel. |
| 3 | +
|
| 4 | +Used by .github/workflows/telegram-publish-files.yml. Reads files from |
| 5 | +--assets-dir, picks a Persian caption per filename, posts via the |
| 6 | +Telegram Bot API `sendDocument` endpoint with --hashtag appended. |
| 7 | +
|
| 8 | +Files larger than the Telegram Bot API's 50 MB ceiling are split into |
| 9 | +~45 MB byte chunks via Python (no `split` shell dep) and posted as |
| 10 | +`<name>.part_aa`, `.part_ab`, ... — recipients reassemble with |
| 11 | +`cat <name>.part_* > <name>`. |
| 12 | +
|
| 13 | +Re-runnable: posts every file every time. Use carefully when re-running |
| 14 | +for the same version (the channel will get duplicate posts). |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import argparse |
| 20 | +import os |
| 21 | +import sys |
| 22 | +import time |
| 23 | +import urllib.error |
| 24 | +import urllib.parse |
| 25 | +import urllib.request |
| 26 | +import json |
| 27 | +from pathlib import Path |
| 28 | + |
| 29 | +# Telegram Bot API uploads cap at 50 MB. Pick 45 MB for chunks so the |
| 30 | +# multipart envelope + caption + Telegram's own overhead don't push us |
| 31 | +# over. Bigger chunks (e.g. 49 MB) sometimes hit "Request Entity Too |
| 32 | +# Large" depending on caption length. |
| 33 | +CHUNK_LIMIT_BYTES = 45 * 1024 * 1024 |
| 34 | + |
| 35 | +# Sleep between uploads. Telegram's documented rate limit is 1 msg/sec |
| 36 | +# to the same chat, plus a soft "burst" allowance. 1.5s is conservative |
| 37 | +# and means a 20-file release publishes in ~30 s. |
| 38 | +INTER_UPLOAD_SLEEP_SECS = 1.5 |
| 39 | + |
| 40 | +# Filename-substring → Persian caption. Order matters: longest / |
| 41 | +# most-specific patterns first, since a shorter pattern (e.g. |
| 42 | +# "android-x86") can match a more-specific filename ("android-x86_64"). |
| 43 | +# Match is `pattern in filename`. |
| 44 | +CAPTIONS: list[tuple[str, str]] = [ |
| 45 | + # Android — universal first (the recommended default for non-technical users). |
| 46 | + ("android-universal", "نسخه اندروید (universal) — برای همه دستگاهها"), |
| 47 | + ("android-arm64-v8a", "نسخه اندروید (arm64-v8a) — گوشیهای مدرن ۶۴ بیتی"), |
| 48 | + ("android-armeabi-v7a", "نسخه اندروید (armv7) — گوشیهای قدیمیتر ۳۲ بیتی"), |
| 49 | + ("android-x86_64", "نسخه اندروید (x86_64) — شبیهساز ۶۴ بیتی"), |
| 50 | + ("android-x86", "نسخه اندروید (x86) — شبیهساز"), |
| 51 | + # Windows. |
| 52 | + ("windows-amd64", "نسخه ویندوز x64 (۶۴ بیتی)"), |
| 53 | + ("windows-i686", "نسخه ویندوز x86 (۳۲ بیتی، Win7+)"), |
| 54 | + # macOS — .app bundles before plain CLI tarballs. |
| 55 | + ("macos-arm64-app", "نسخه macOS (Apple Silicon) — برنامه گرافیکی .app"), |
| 56 | + ("macos-amd64-app", "نسخه macOS (Intel) — برنامه گرافیکی .app"), |
| 57 | + ("macos-arm64", "نسخه macOS (Apple Silicon) — CLI"), |
| 58 | + ("macos-amd64", "نسخه macOS (Intel) — CLI"), |
| 59 | + # Linux — musl static first, glibc second. |
| 60 | + ("linux-musl-amd64", "نسخه لینوکس amd64 (musl static) — Alpine / OpenWRT-x86"), |
| 61 | + ("linux-musl-arm64", "نسخه لینوکس arm64 (musl static)"), |
| 62 | + ("linux-amd64", "نسخه لینوکس amd64 (glibc)"), |
| 63 | + ("linux-arm64", "نسخه لینوکس arm64 (glibc)"), |
| 64 | + # Embedded targets. |
| 65 | + ("openwrt-mipsel-softfloat", "نسخه OpenWRT (mipsel softfloat) — روتر MT7621"), |
| 66 | + ("raspbian-armhf", "نسخه Raspbian (armhf) — رزبری پای ۳۲ بیتی"), |
| 67 | +] |
| 68 | + |
| 69 | + |
| 70 | +def caption_for(filename: str) -> str: |
| 71 | + """Return the Persian caption for a filename, falling back to the |
| 72 | + bare filename if nothing matches.""" |
| 73 | + for pattern, persian in CAPTIONS: |
| 74 | + if pattern in filename: |
| 75 | + return persian |
| 76 | + return f"نسخه `{filename}`" |
| 77 | + |
| 78 | + |
| 79 | +def order_files(files: list[Path]) -> list[Path]: |
| 80 | + """Sort release files in CAPTIONS order (Android first, then |
| 81 | + Windows, macOS, Linux, embedded). Files not matching any pattern |
| 82 | + fall to the end in alphabetical order.""" |
| 83 | + order_map: dict[str, int] = {pattern: idx for idx, (pattern, _) in enumerate(CAPTIONS)} |
| 84 | + |
| 85 | + def key(p: Path) -> tuple[int, str]: |
| 86 | + for pattern, idx in order_map.items(): |
| 87 | + if pattern in p.name: |
| 88 | + return (idx, p.name) |
| 89 | + # Unknown patterns: push to end, alphabetize among themselves. |
| 90 | + return (len(CAPTIONS), p.name) |
| 91 | + |
| 92 | + return sorted(files, key=key) |
| 93 | + |
| 94 | + |
| 95 | +def split_file(path: Path, chunk_bytes: int) -> list[Path]: |
| 96 | + """Split `path` into chunks of at most `chunk_bytes` bytes. Returns |
| 97 | + the list of chunk paths, named `<orig>.part_aa`, `.part_ab`, ... |
| 98 | + Mimics `split -b <chunk_bytes>`. Reassembled via |
| 99 | + `cat <name>.part_* > <name>`. |
| 100 | +
|
| 101 | + Skips work if existing parts are already present (idempotent re-run).""" |
| 102 | + parts: list[Path] = [] |
| 103 | + |
| 104 | + def part_name(idx: int) -> str: |
| 105 | + # 26-letter base: aa..az, ba..bz, ... mirroring split's default. |
| 106 | + first = chr(ord("a") + idx // 26) |
| 107 | + second = chr(ord("a") + idx % 26) |
| 108 | + return f"{path.name}.part_{first}{second}" |
| 109 | + |
| 110 | + idx = 0 |
| 111 | + with path.open("rb") as src: |
| 112 | + while True: |
| 113 | + buf = src.read(chunk_bytes) |
| 114 | + if not buf: |
| 115 | + break |
| 116 | + part_path = path.parent / part_name(idx) |
| 117 | + with part_path.open("wb") as dst: |
| 118 | + dst.write(buf) |
| 119 | + parts.append(part_path) |
| 120 | + idx += 1 |
| 121 | + return parts |
| 122 | + |
| 123 | + |
| 124 | +def send_document( |
| 125 | + bot_token: str, |
| 126 | + chat_id: str, |
| 127 | + file_path: Path, |
| 128 | + caption: str, |
| 129 | +) -> dict: |
| 130 | + """POST a single file via the Telegram Bot API sendDocument endpoint. |
| 131 | + Returns the parsed JSON response. Raises on HTTP error. |
| 132 | +
|
| 133 | + Uses urllib + a hand-rolled multipart/form-data encoder so we don't |
| 134 | + pull `requests` (the workflow runs on stock GitHub-hosted runners |
| 135 | + where stdlib-only is preferable for cold-start speed).""" |
| 136 | + url = f"https://api.telegram.org/bot{bot_token}/sendDocument" |
| 137 | + boundary = "----mhrvUploadBoundary" + str(int(time.time() * 1000)) |
| 138 | + body = build_multipart( |
| 139 | + boundary, |
| 140 | + fields={ |
| 141 | + "chat_id": chat_id, |
| 142 | + "caption": caption, |
| 143 | + "parse_mode": "HTML", |
| 144 | + # Disable preview to keep the channel tidy. |
| 145 | + "disable_notification": "false", |
| 146 | + }, |
| 147 | + files={"document": (file_path.name, file_path.read_bytes(), "application/octet-stream")}, |
| 148 | + ) |
| 149 | + req = urllib.request.Request( |
| 150 | + url, |
| 151 | + data=body, |
| 152 | + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, |
| 153 | + method="POST", |
| 154 | + ) |
| 155 | + # 5 minute timeout for the actual upload — Telegram occasionally |
| 156 | + # takes a while to process 40+ MB documents. |
| 157 | + with urllib.request.urlopen(req, timeout=300) as resp: |
| 158 | + return json.loads(resp.read().decode("utf-8")) |
| 159 | + |
| 160 | + |
| 161 | +def build_multipart( |
| 162 | + boundary: str, |
| 163 | + fields: dict[str, str], |
| 164 | + files: dict[str, tuple[str, bytes, str]], |
| 165 | +) -> bytes: |
| 166 | + """Build a multipart/form-data body. `files` is name → (filename, |
| 167 | + bytes, mime). Plain stdlib so we don't need `requests`.""" |
| 168 | + parts: list[bytes] = [] |
| 169 | + crlf = b"\r\n" |
| 170 | + bnd = f"--{boundary}".encode() |
| 171 | + |
| 172 | + for name, value in fields.items(): |
| 173 | + parts.append(bnd) |
| 174 | + parts.append(f'Content-Disposition: form-data; name="{name}"'.encode()) |
| 175 | + parts.append(b"") |
| 176 | + parts.append(value.encode("utf-8")) |
| 177 | + |
| 178 | + for name, (filename, data, mime) in files.items(): |
| 179 | + parts.append(bnd) |
| 180 | + parts.append( |
| 181 | + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"'.encode() |
| 182 | + ) |
| 183 | + parts.append(f"Content-Type: {mime}".encode()) |
| 184 | + parts.append(b"") |
| 185 | + parts.append(data) |
| 186 | + |
| 187 | + parts.append(f"--{boundary}--".encode()) |
| 188 | + parts.append(b"") |
| 189 | + return crlf.join(parts) |
| 190 | + |
| 191 | + |
| 192 | +def html_escape(s: str) -> str: |
| 193 | + return s.replace("&", "&").replace("<", "<").replace(">", ">") |
| 194 | + |
| 195 | + |
| 196 | +def post_file( |
| 197 | + bot_token: str, |
| 198 | + chat_id: str, |
| 199 | + file_path: Path, |
| 200 | + base_caption: str, |
| 201 | + hashtag: str, |
| 202 | +) -> bool: |
| 203 | + """Post one file. If too big, split + post each part. Returns True |
| 204 | + on success of all parts, False on any failure.""" |
| 205 | + size = file_path.stat().st_size |
| 206 | + if size <= CHUNK_LIMIT_BYTES: |
| 207 | + caption = ( |
| 208 | + f"<b>{html_escape(base_caption)}</b>\n" |
| 209 | + f"<code>{html_escape(file_path.name)}</code>\n" |
| 210 | + f"\n{hashtag}" |
| 211 | + ) |
| 212 | + print(f" uploading {file_path.name} ({size / 1_048_576:.1f} MB)...", flush=True) |
| 213 | + try: |
| 214 | + resp = send_document(bot_token, chat_id, file_path, caption) |
| 215 | + if not resp.get("ok"): |
| 216 | + print(f" !! Telegram returned not-ok: {resp}", flush=True) |
| 217 | + return False |
| 218 | + print(f" ok (message_id={resp['result']['message_id']})", flush=True) |
| 219 | + return True |
| 220 | + except urllib.error.HTTPError as e: |
| 221 | + err_body = e.read().decode("utf-8", errors="replace")[:500] |
| 222 | + print(f" !! HTTP {e.code}: {err_body}", flush=True) |
| 223 | + return False |
| 224 | + except Exception as e: |
| 225 | + print(f" !! exception: {e}", flush=True) |
| 226 | + return False |
| 227 | + finally: |
| 228 | + time.sleep(INTER_UPLOAD_SLEEP_SECS) |
| 229 | + |
| 230 | + # Too big — split and post each part. |
| 231 | + print( |
| 232 | + f" splitting {file_path.name} ({size / 1_048_576:.1f} MB > " |
| 233 | + f"{CHUNK_LIMIT_BYTES / 1_048_576:.0f} MB ceiling)...", |
| 234 | + flush=True, |
| 235 | + ) |
| 236 | + parts = split_file(file_path, CHUNK_LIMIT_BYTES) |
| 237 | + if not parts: |
| 238 | + print(f" !! split produced 0 parts (empty file?)", flush=True) |
| 239 | + return False |
| 240 | + |
| 241 | + n = len(parts) |
| 242 | + all_ok = True |
| 243 | + for idx, part_path in enumerate(parts, start=1): |
| 244 | + part_caption = ( |
| 245 | + f"<b>{html_escape(base_caption)} — قسمت {idx}/{n}</b>\n" |
| 246 | + f"<code>{html_escape(part_path.name)}</code>\n" |
| 247 | + f"\nبرای بازسازی فایل اصلی:\n" |
| 248 | + f"<code>cat {html_escape(file_path.name)}.part_* > " |
| 249 | + f"{html_escape(file_path.name)}</code>\n" |
| 250 | + f"\n{hashtag}" |
| 251 | + ) |
| 252 | + psize = part_path.stat().st_size |
| 253 | + print( |
| 254 | + f" uploading part {idx}/{n}: {part_path.name} ({psize / 1_048_576:.1f} MB)...", |
| 255 | + flush=True, |
| 256 | + ) |
| 257 | + try: |
| 258 | + resp = send_document(bot_token, chat_id, part_path, part_caption) |
| 259 | + if not resp.get("ok"): |
| 260 | + print(f" !! Telegram returned not-ok: {resp}", flush=True) |
| 261 | + all_ok = False |
| 262 | + else: |
| 263 | + print( |
| 264 | + f" ok (message_id={resp['result']['message_id']})", flush=True |
| 265 | + ) |
| 266 | + except urllib.error.HTTPError as e: |
| 267 | + err_body = e.read().decode("utf-8", errors="replace")[:500] |
| 268 | + print(f" !! HTTP {e.code}: {err_body}", flush=True) |
| 269 | + all_ok = False |
| 270 | + except Exception as e: |
| 271 | + print(f" !! exception: {e}", flush=True) |
| 272 | + all_ok = False |
| 273 | + finally: |
| 274 | + time.sleep(INTER_UPLOAD_SLEEP_SECS) |
| 275 | + # Tidy up the part file once posted. |
| 276 | + try: |
| 277 | + part_path.unlink() |
| 278 | + except OSError: |
| 279 | + pass |
| 280 | + |
| 281 | + return all_ok |
| 282 | + |
| 283 | + |
| 284 | +def main() -> int: |
| 285 | + parser = argparse.ArgumentParser(description=__doc__) |
| 286 | + parser.add_argument("--assets-dir", required=True, type=Path) |
| 287 | + parser.add_argument("--version", required=True, help="e.g. 1.8.0") |
| 288 | + parser.add_argument("--hashtag", required=True, help="e.g. #v180") |
| 289 | + args = parser.parse_args() |
| 290 | + |
| 291 | + bot_token = os.environ.get("BOT_TOKEN") |
| 292 | + chat_id = os.environ.get("CHAT_ID") |
| 293 | + if not bot_token or not chat_id: |
| 294 | + print("BOT_TOKEN and CHAT_ID env vars required", file=sys.stderr) |
| 295 | + return 2 |
| 296 | + |
| 297 | + if not args.assets_dir.is_dir(): |
| 298 | + print(f"--assets-dir {args.assets_dir} not a directory", file=sys.stderr) |
| 299 | + return 2 |
| 300 | + |
| 301 | + # Collect all regular files in the directory (no recursion). Skip |
| 302 | + # split-part leftovers from a previous run of this script if any |
| 303 | + # exist — we'll regenerate cleanly. |
| 304 | + raw_files = [ |
| 305 | + p for p in args.assets_dir.iterdir() |
| 306 | + if p.is_file() and ".part_" not in p.name |
| 307 | + ] |
| 308 | + if not raw_files: |
| 309 | + print(f"no files found in {args.assets_dir}", file=sys.stderr) |
| 310 | + return 2 |
| 311 | + |
| 312 | + files = order_files(raw_files) |
| 313 | + print(f"publishing {len(files)} file(s) to Telegram chat {chat_id} for v{args.version}:") |
| 314 | + for f in files: |
| 315 | + print(f" - {f.name}") |
| 316 | + print() |
| 317 | + |
| 318 | + # Optional: a leading announcement message that anchors the file |
| 319 | + # batch. Posted as a regular sendMessage so it shows above the file |
| 320 | + # group in the channel and gives recipients a single hashtag link |
| 321 | + # to find this release later. |
| 322 | + announce = ( |
| 323 | + f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>\n" |
| 324 | + f"\nفایلها در ادامه به ترتیب پلتفرم ارسال میشن.\n" |
| 325 | + f"\n{args.hashtag}" |
| 326 | + ) |
| 327 | + try: |
| 328 | + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" |
| 329 | + data = urllib.parse.urlencode({ |
| 330 | + "chat_id": chat_id, |
| 331 | + "text": announce, |
| 332 | + "parse_mode": "HTML", |
| 333 | + "disable_web_page_preview": "true", |
| 334 | + }).encode() |
| 335 | + with urllib.request.urlopen( |
| 336 | + urllib.request.Request(url, data=data, method="POST"), timeout=30 |
| 337 | + ) as resp: |
| 338 | + r = json.loads(resp.read().decode("utf-8")) |
| 339 | + if not r.get("ok"): |
| 340 | + print(f" !! announcement failed: {r}", flush=True) |
| 341 | + else: |
| 342 | + print(f" announcement posted (message_id={r['result']['message_id']})", flush=True) |
| 343 | + except Exception as e: |
| 344 | + # Non-fatal: continue with file uploads even if announcement bombs. |
| 345 | + print(f" !! announcement exception: {e}", flush=True) |
| 346 | + time.sleep(INTER_UPLOAD_SLEEP_SECS) |
| 347 | + |
| 348 | + failures = 0 |
| 349 | + for f in files: |
| 350 | + base = caption_for(f.name) |
| 351 | + ok = post_file(bot_token, chat_id, f, base, args.hashtag) |
| 352 | + if not ok: |
| 353 | + failures += 1 |
| 354 | + |
| 355 | + print() |
| 356 | + if failures: |
| 357 | + print(f"DONE with {failures} failure(s) out of {len(files)}", flush=True) |
| 358 | + return 1 |
| 359 | + print(f"DONE — {len(files)} files posted successfully", flush=True) |
| 360 | + return 0 |
| 361 | + |
| 362 | + |
| 363 | +if __name__ == "__main__": |
| 364 | + sys.exit(main()) |
0 commit comments