Skip to content

Commit 7e5e2c7

Browse files
therealalephclaude
andcommitted
ci(telegram): publish each release file individually to channel
New workflow + script that posts every artifact (Android APKs, Windows ZIP, macOS .app + CLI tarballs, Linux glibc + musl, OpenWRT, Raspbian) to the Telegram channel as separate sendDocument calls, each with a Persian caption naming the platform variant and a `#v<NNN>` hashtag (e.g. `#v180`, `#v1810`, `#v200`) so users can find a specific release later via the channel's hashtag search. Files larger than 45 MB (the Bot API's effective ceiling after multipart + caption overhead) are split into byte chunks named `<name>.part_aa`, `.part_ab`, ... and posted with reassembly instructions in the caption. For the v1.8.0 file set everything is ≤41 MB so the split path is defensive. Decoupled from `release.yml` so it can be re-triggered for any past tag via `workflow_dispatch` without rebuilding artifacts — downloads from the GitHub Release page directly via `gh release download`. Also auto-runs on each successful `release.yml` completion via `workflow_run`. Hard-codes the channel ID `-1003966234444` (one well-known channel, auditable in source). Reuses `secrets.TELEGRAM_BOT_TOKEN` which already has post permissions there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2c4c0a9 commit 7e5e2c7

2 files changed

Lines changed: 480 additions & 0 deletions

File tree

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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_* &gt; "
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

Comments
 (0)