diff --git a/.github/scripts/telegram_release_notify.py b/.github/scripts/telegram_release_notify.py new file mode 100755 index 0000000..cf28c8a --- /dev/null +++ b/.github/scripts/telegram_release_notify.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Post a CI-built Android APK to the project Telegram channel on each +release tag, followed by a reply-threaded changelog message with +Persian + English bullets in
blocks. + +Called from the `telegram:` job in `.github/workflows/release.yml`. +Environment: + BOT_TOKEN Telegram bot token (repo secret TELEGRAM_BOT_TOKEN) + CHAT_ID Numeric chat id, e.g. -1002282061190 (repo secret + TELEGRAM_CHAT_ID) +Arguments: + --apk path to the APK file to upload + --version bare version string, e.g. "1.1.0" + --repo "owner/repo" + --changelog path to docs/changelog/vX.Y.Z.md; split on a line + that is exactly "---" — anything before is Persian, + anything after is English. Missing file = only the + APK is posted (no reply). + +Why Python over curl: curl's `-F name=value` multipart spec treats +`` tags, which triggers the file-read +path and exits 26 "Failed to open/read local data". urllib has no +such behavior. + +Telegram quirks we deliberately handle: + - Captions max out at 1024 chars, so the APK caption is short + (title + sha256 + repo + release URL) and the real changelog + goes in a reply-threaded message (sendMessage has no practical + length limit). + - sendDocument content-type defaults to application/octet-stream + for unknown extensions — we pass .apk with + application/vnd.android.package-archive so channel previews + label it as an Android package, not a generic file. +""" +import argparse +import hashlib +import http.client +import json +import os +import re +import ssl +import sys +import uuid +from pathlib import Path + + +def parse_changelog(path: str) -> tuple[str, str]: + """Return (persian_body, english_body). Blank strings if file missing.""" + p = Path(path) + if not p.is_file(): + return "", "" + body = p.read_text(encoding="utf-8") + # Strip a leading HTML comment block if present — the changelog + # template uses to document the format for editors; + # we don't want that echoed to Telegram. + body = re.sub(r"^\s*\s*", "", body, count=1, flags=re.S) + fa, sep, en = body.partition("\n---\n") + if not sep: + # No separator — treat everything as Persian (content-language + # is a project preference rather than a hard rule). + return body.strip(), "" + return fa.strip(), en.strip() + + +def sha256_of(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict: + """POST `body` to https://api.telegram.org/bot / .""" + conn = http.client.HTTPSConnection( + "api.telegram.org", context=ssl.create_default_context() + ) + conn.request( + "POST", + f"/bot{token}/{method}", + body=body, + headers={"Content-Type": content_type, "Content-Length": str(len(body))}, + ) + resp = conn.getresponse() + raw = resp.read() + try: + data = json.loads(raw) + except json.JSONDecodeError: + raise SystemExit(f"Telegram {method}: non-JSON response ({resp.status}): {raw!r}") + if not data.get("ok"): + raise SystemExit(f"Telegram {method} failed: {data}") + return data["result"] + + +def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int: + """Upload the APK file with a short HTML caption. Returns message_id.""" + boundary = "----" + uuid.uuid4().hex + with open(apk_path, "rb") as f: + file_bytes = f.read() + + def text_field(name: str, value: str) -> bytes: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n\r\n' + f"{value}\r\n" + ).encode("utf-8") + + def file_field(name: str, filename: str, content: bytes) -> bytes: + head = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n' + # Proper MIME type — makes the Telegram client show the APK + # with the Android package icon and honour its size/name. + f"Content-Type: application/vnd.android.package-archive\r\n\r\n" + ).encode("utf-8") + return head + content + b"\r\n" + + body = ( + text_field("chat_id", chat_id) + + text_field("caption", caption) + + text_field("parse_mode", "HTML") + + file_field("document", os.path.basename(apk_path), file_bytes) + + f"--{boundary}--\r\n".encode("utf-8") + ) + + result = tg_request( + "sendDocument", + token, + body=body, + content_type=f"multipart/form-data; boundary={boundary}", + ) + return int(result["message_id"]) + + +def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None: + """Post a text message as a reply to the APK message.""" + from urllib.parse import urlencode + + body = urlencode( + { + "chat_id": chat_id, + "text": text, + "parse_mode": "HTML", + "reply_to_message_id": str(reply_to), + } + ).encode() + tg_request( + "sendMessage", + token, + body=body, + content_type="application/x-www-form-urlencoded", + ) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--apk", required=True) + ap.add_argument("--version", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--changelog", required=True) + args = ap.parse_args() + + token = os.environ.get("BOT_TOKEN", "") + chat_id = os.environ.get("CHAT_ID", "") + if not token or not chat_id: + print("TELEGRAM secrets not present, skipping post.") + return 0 + + ver = args.version + sha = sha256_of(args.apk) + caption = ( + f"mhrv-rs Android v{ver}\n\n" + f"SHA-256: {sha}\n" + f"https://github.com/{args.repo}\n" + f"https://github.com/{args.repo}/releases/tag/v{ver}" + ) + + doc_mid = send_document(token, chat_id, args.apk, caption) + print(f"sendDocument OK, message_id={doc_mid}") + + fa, en = parse_changelog(args.changelog) + if not fa and not en: + print(f"No changelog at {args.changelog}, skipping reply.") + return 0 + + parts = [] + if fa: + parts.append(f"{fa}") + if en: + parts.append(f"{en}") + reply = "\n\n".join(parts) + + send_reply(token, chat_id, reply, doc_mid) + print("Reply OK") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fdbd4b..50115d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -374,10 +374,14 @@ jobs: env: BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + # Python over curl/bash so we don't have to fight curl's -F + # value-interpretation rules. curl treats `-F "caption=<..."` + # as "read the caption from file named ..." when the value + # starts with `<`, which matches our `` HTML-bold tags and + # silently turns the whole job into a "file not found" exit + # 26. Python stdlib has no such wart. run: | set -euo pipefail - - # Pull the tag off refs/tags/v1.2.3 → "1.2.3". VER="${GITHUB_REF#refs/tags/v}" APK="apk/mhrv-rs-android-universal-v${VER}.apk" @@ -387,63 +391,13 @@ jobs: fi if [ ! -f "$APK" ]; then - echo "::error::expected $APK to exist; actually got:" + echo "::error::expected $APK to exist; got:" ls -la apk/ exit 1 fi - SHA256=$(sha256sum "$APK" | awk '{print $1}') - CAPTION="mhrv-rs Android v${VER} - - SHA-256:${SHA256}- https://github.com/${GITHUB_REPOSITORY} - https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${VER}" - - echo "Sending APK with short caption..." - DOC_RESP=$(curl -sS --fail-with-body -X POST \ - "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \ - -F "chat_id=${CHAT_ID}" \ - -F "document=@${APK}" \ - -F "caption=${CAPTION}" \ - -F "parse_mode=HTML") - DOC_MID=$(printf '%s' "$DOC_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['message_id'])") - echo "sendDocument OK, message_id=$DOC_MID" - - # Full changelog as a reply. Split on the first line that's - # exactly `---`; anything before = Persian, after = English. - # Missing changelog file → skip the reply (not fatal). - CL_FILE="docs/changelog/v${VER}.md" - if [ ! -f "$CL_FILE" ]; then - echo "::notice::no $CL_FILE, skipping changelog reply" - exit 0 - fi - - python3 - "$CL_FILE" <<'PY' > /tmp/cl_fa.txt - import sys - body = open(sys.argv[1]).read() - # Strip the HTML comment header (between ). - import re - body = re.sub(r'\s*', '', body, count=1, flags=re.S) - fa, _, _ = body.partition('\n---\n') - sys.stdout.write(fa.strip()) - PY - python3 - "$CL_FILE" <<'PY' > /tmp/cl_en.txt - import sys, re - body = open(sys.argv[1]).read() - body = re.sub(r'\s*', '', body, count=1, flags=re.S) - _, _, en = body.partition('\n---\n') - sys.stdout.write(en.strip()) - PY - - REPLY="$(cat /tmp/cl_fa.txt)- -$(cat /tmp/cl_en.txt)" - - echo "Sending changelog reply..." - curl -sS --fail-with-body -X POST \ - "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \ - --data-urlencode "chat_id=${CHAT_ID}" \ - --data-urlencode "text=${REPLY}" \ - --data-urlencode "parse_mode=HTML" \ - --data-urlencode "reply_to_message_id=${DOC_MID}" > /dev/null - echo "Reply OK" + python3 .github/scripts/telegram_release_notify.py \ + --apk "$APK" \ + --version "$VER" \ + --repo "$GITHUB_REPOSITORY" \ + --changelog "docs/changelog/v${VER}.md"