Skip to content

Commit d80bed3

Browse files
committed
ci(telegram): move notify step to Python, fix curl < parse bug
The v1.1.0 CI telegram job failed with curl exit 26 "Failed to open/read local data from file/application" because: -F "caption=<b>mhrv-rs Android v1.1.0</b>..." curl's -F treats a value starting with `<` as "read from file named ..." (the canonical way to put file CONTENTS into a text form field). Our HTML captions start with `<b>`, so curl tried to open a file named `b>mhrv-rs Android v1.1.0</b>...`, failed, and the whole job went red. Rewrote the step in Python (`.github/scripts/telegram_release_notify.py`). stdlib urllib + http.client have no such value-interpretation wart. Also: - uses `application/vnd.android.package-archive` content-type so Telegram shows the APK with an Android-package label, not generic octet-stream - proper sha256 hash (streaming, not shell-piped) - consolidated the two shell-script HEREDOCs that were parsing the changelog into one place - clean exit codes: "no changelog file" and "no secrets" both exit 0, a broken Telegram response exits non-zero No behaviour change for callers — the workflow just calls the script with the same four inputs.
1 parent 989643d commit d80bed3

2 files changed

Lines changed: 213 additions & 58 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Post a CI-built Android APK to the project Telegram channel on each
4+
release tag, followed by a reply-threaded changelog message with
5+
Persian + English bullets in <blockquote> blocks.
6+
7+
Called from the `telegram:` job in `.github/workflows/release.yml`.
8+
Environment:
9+
BOT_TOKEN Telegram bot token (repo secret TELEGRAM_BOT_TOKEN)
10+
CHAT_ID Numeric chat id, e.g. -1002282061190 (repo secret
11+
TELEGRAM_CHAT_ID)
12+
Arguments:
13+
--apk path to the APK file to upload
14+
--version bare version string, e.g. "1.1.0"
15+
--repo "owner/repo"
16+
--changelog path to docs/changelog/vX.Y.Z.md; split on a line
17+
that is exactly "---" — anything before is Persian,
18+
anything after is English. Missing file = only the
19+
APK is posted (no reply).
20+
21+
Why Python over curl: curl's `-F name=value` multipart spec treats
22+
`<file` as "read from file" and `@file` as "upload file". Our HTML
23+
captions contain literal `<b>` tags, which triggers the file-read
24+
path and exits 26 "Failed to open/read local data". urllib has no
25+
such behavior.
26+
27+
Telegram quirks we deliberately handle:
28+
- Captions max out at 1024 chars, so the APK caption is short
29+
(title + sha256 + repo + release URL) and the real changelog
30+
goes in a reply-threaded message (sendMessage has no practical
31+
length limit).
32+
- sendDocument content-type defaults to application/octet-stream
33+
for unknown extensions — we pass .apk with
34+
application/vnd.android.package-archive so channel previews
35+
label it as an Android package, not a generic file.
36+
"""
37+
import argparse
38+
import hashlib
39+
import http.client
40+
import json
41+
import os
42+
import re
43+
import ssl
44+
import sys
45+
import uuid
46+
from pathlib import Path
47+
48+
49+
def parse_changelog(path: str) -> tuple[str, str]:
50+
"""Return (persian_body, english_body). Blank strings if file missing."""
51+
p = Path(path)
52+
if not p.is_file():
53+
return "", ""
54+
body = p.read_text(encoding="utf-8")
55+
# Strip a leading HTML comment block if present — the changelog
56+
# template uses <!-- ... --> to document the format for editors;
57+
# we don't want that echoed to Telegram.
58+
body = re.sub(r"^\s*<!--.*?-->\s*", "", body, count=1, flags=re.S)
59+
fa, sep, en = body.partition("\n---\n")
60+
if not sep:
61+
# No separator — treat everything as Persian (content-language
62+
# is a project preference rather than a hard rule).
63+
return body.strip(), ""
64+
return fa.strip(), en.strip()
65+
66+
67+
def sha256_of(path: str) -> str:
68+
h = hashlib.sha256()
69+
with open(path, "rb") as f:
70+
for chunk in iter(lambda: f.read(1024 * 1024), b""):
71+
h.update(chunk)
72+
return h.hexdigest()
73+
74+
75+
def tg_request(method: str, token: str, *, body: bytes, content_type: str) -> dict:
76+
"""POST `body` to https://api.telegram.org/bot<token>/<method>."""
77+
conn = http.client.HTTPSConnection(
78+
"api.telegram.org", context=ssl.create_default_context()
79+
)
80+
conn.request(
81+
"POST",
82+
f"/bot{token}/{method}",
83+
body=body,
84+
headers={"Content-Type": content_type, "Content-Length": str(len(body))},
85+
)
86+
resp = conn.getresponse()
87+
raw = resp.read()
88+
try:
89+
data = json.loads(raw)
90+
except json.JSONDecodeError:
91+
raise SystemExit(f"Telegram {method}: non-JSON response ({resp.status}): {raw!r}")
92+
if not data.get("ok"):
93+
raise SystemExit(f"Telegram {method} failed: {data}")
94+
return data["result"]
95+
96+
97+
def send_document(token: str, chat_id: str, apk_path: str, caption: str) -> int:
98+
"""Upload the APK file with a short HTML caption. Returns message_id."""
99+
boundary = "----" + uuid.uuid4().hex
100+
with open(apk_path, "rb") as f:
101+
file_bytes = f.read()
102+
103+
def text_field(name: str, value: str) -> bytes:
104+
return (
105+
f"--{boundary}\r\n"
106+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
107+
f"{value}\r\n"
108+
).encode("utf-8")
109+
110+
def file_field(name: str, filename: str, content: bytes) -> bytes:
111+
head = (
112+
f"--{boundary}\r\n"
113+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
114+
# Proper MIME type — makes the Telegram client show the APK
115+
# with the Android package icon and honour its size/name.
116+
f"Content-Type: application/vnd.android.package-archive\r\n\r\n"
117+
).encode("utf-8")
118+
return head + content + b"\r\n"
119+
120+
body = (
121+
text_field("chat_id", chat_id)
122+
+ text_field("caption", caption)
123+
+ text_field("parse_mode", "HTML")
124+
+ file_field("document", os.path.basename(apk_path), file_bytes)
125+
+ f"--{boundary}--\r\n".encode("utf-8")
126+
)
127+
128+
result = tg_request(
129+
"sendDocument",
130+
token,
131+
body=body,
132+
content_type=f"multipart/form-data; boundary={boundary}",
133+
)
134+
return int(result["message_id"])
135+
136+
137+
def send_reply(token: str, chat_id: str, text: str, reply_to: int) -> None:
138+
"""Post a text message as a reply to the APK message."""
139+
from urllib.parse import urlencode
140+
141+
body = urlencode(
142+
{
143+
"chat_id": chat_id,
144+
"text": text,
145+
"parse_mode": "HTML",
146+
"reply_to_message_id": str(reply_to),
147+
}
148+
).encode()
149+
tg_request(
150+
"sendMessage",
151+
token,
152+
body=body,
153+
content_type="application/x-www-form-urlencoded",
154+
)
155+
156+
157+
def main() -> int:
158+
ap = argparse.ArgumentParser()
159+
ap.add_argument("--apk", required=True)
160+
ap.add_argument("--version", required=True)
161+
ap.add_argument("--repo", required=True)
162+
ap.add_argument("--changelog", required=True)
163+
args = ap.parse_args()
164+
165+
token = os.environ.get("BOT_TOKEN", "")
166+
chat_id = os.environ.get("CHAT_ID", "")
167+
if not token or not chat_id:
168+
print("TELEGRAM secrets not present, skipping post.")
169+
return 0
170+
171+
ver = args.version
172+
sha = sha256_of(args.apk)
173+
caption = (
174+
f"<b>mhrv-rs Android v{ver}</b>\n\n"
175+
f"SHA-256: <code>{sha}</code>\n"
176+
f"https://github.com/{args.repo}\n"
177+
f"https://github.com/{args.repo}/releases/tag/v{ver}"
178+
)
179+
180+
doc_mid = send_document(token, chat_id, args.apk, caption)
181+
print(f"sendDocument OK, message_id={doc_mid}")
182+
183+
fa, en = parse_changelog(args.changelog)
184+
if not fa and not en:
185+
print(f"No changelog at {args.changelog}, skipping reply.")
186+
return 0
187+
188+
parts = []
189+
if fa:
190+
parts.append(f"<blockquote>{fa}</blockquote>")
191+
if en:
192+
parts.append(f"<blockquote>{en}</blockquote>")
193+
reply = "\n\n".join(parts)
194+
195+
send_reply(token, chat_id, reply, doc_mid)
196+
print("Reply OK")
197+
return 0
198+
199+
200+
if __name__ == "__main__":
201+
sys.exit(main())

.github/workflows/release.yml

Lines changed: 12 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -374,10 +374,14 @@ jobs:
374374
env:
375375
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
376376
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
377+
# Python over curl/bash so we don't have to fight curl's -F
378+
# value-interpretation rules. curl treats `-F "caption=<..."`
379+
# as "read the caption from file named ..." when the value
380+
# starts with `<`, which matches our `<b>` HTML-bold tags and
381+
# silently turns the whole job into a "file not found" exit
382+
# 26. Python stdlib has no such wart.
377383
run: |
378384
set -euo pipefail
379-
380-
# Pull the tag off refs/tags/v1.2.3 → "1.2.3".
381385
VER="${GITHUB_REF#refs/tags/v}"
382386
APK="apk/mhrv-rs-android-universal-v${VER}.apk"
383387
@@ -387,63 +391,13 @@ jobs:
387391
fi
388392
389393
if [ ! -f "$APK" ]; then
390-
echo "::error::expected $APK to exist; actually got:"
394+
echo "::error::expected $APK to exist; got:"
391395
ls -la apk/
392396
exit 1
393397
fi
394398
395-
SHA256=$(sha256sum "$APK" | awk '{print $1}')
396-
CAPTION="<b>mhrv-rs Android v${VER}</b>
397-
398-
SHA-256: <code>${SHA256}</code>
399-
https://github.com/${GITHUB_REPOSITORY}
400-
https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${VER}"
401-
402-
echo "Sending APK with short caption..."
403-
DOC_RESP=$(curl -sS --fail-with-body -X POST \
404-
"https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \
405-
-F "chat_id=${CHAT_ID}" \
406-
-F "document=@${APK}" \
407-
-F "caption=${CAPTION}" \
408-
-F "parse_mode=HTML")
409-
DOC_MID=$(printf '%s' "$DOC_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['message_id'])")
410-
echo "sendDocument OK, message_id=$DOC_MID"
411-
412-
# Full changelog as a reply. Split on the first line that's
413-
# exactly `---`; anything before = Persian, after = English.
414-
# Missing changelog file → skip the reply (not fatal).
415-
CL_FILE="docs/changelog/v${VER}.md"
416-
if [ ! -f "$CL_FILE" ]; then
417-
echo "::notice::no $CL_FILE, skipping changelog reply"
418-
exit 0
419-
fi
420-
421-
python3 - "$CL_FILE" <<'PY' > /tmp/cl_fa.txt
422-
import sys
423-
body = open(sys.argv[1]).read()
424-
# Strip the HTML comment header (between <!-- and -->).
425-
import re
426-
body = re.sub(r'<!--.*?-->\s*', '', body, count=1, flags=re.S)
427-
fa, _, _ = body.partition('\n---\n')
428-
sys.stdout.write(fa.strip())
429-
PY
430-
python3 - "$CL_FILE" <<'PY' > /tmp/cl_en.txt
431-
import sys, re
432-
body = open(sys.argv[1]).read()
433-
body = re.sub(r'<!--.*?-->\s*', '', body, count=1, flags=re.S)
434-
_, _, en = body.partition('\n---\n')
435-
sys.stdout.write(en.strip())
436-
PY
437-
438-
REPLY="<blockquote>$(cat /tmp/cl_fa.txt)</blockquote>
439-
440-
<blockquote>$(cat /tmp/cl_en.txt)</blockquote>"
441-
442-
echo "Sending changelog reply..."
443-
curl -sS --fail-with-body -X POST \
444-
"https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
445-
--data-urlencode "chat_id=${CHAT_ID}" \
446-
--data-urlencode "text=${REPLY}" \
447-
--data-urlencode "parse_mode=HTML" \
448-
--data-urlencode "reply_to_message_id=${DOC_MID}" > /dev/null
449-
echo "Reply OK"
399+
python3 .github/scripts/telegram_release_notify.py \
400+
--apk "$APK" \
401+
--version "$VER" \
402+
--repo "$GITHUB_REPOSITORY" \
403+
--changelog "docs/changelog/v${VER}.md"

0 commit comments

Comments
 (0)