Skip to content

Commit 0669b93

Browse files
therealalephclaude
andcommitted
ci(telegram): add SHA-256 to file captions + cross-link main channel to files channel
Two changes on top of last commit: 1. SHA-256 ("تایید اصالت") now in every file caption. Each artifact's caption gets a `<code>...</code>` line with the file's SHA-256 hex so recipients can `sha256sum <file>` after download and verify it matches what the channel posted. Defends against modified copies if the channel ever gets relayed through a third party. For chunked uploads (file > 45 MB), each part shows BOTH: - SHA-256 of that specific part (verifies the chunk downloaded intact before bothering to reassemble) - SHA-256 of the full reassembled file (verifies the final result after `cat <name>.part_* > <name>`) 2. Main channel post is now a cross-link, not files. Previously the legacy `telegram` job in release.yml posted the universal APK + full changelog as one sendDocument + sendMessage pair to the main announcement channel. New behaviour: telegram-publish-files.yml's last step posts a short message to the main channel saying "v1.8.0 released, click here for files" with a t.me link pointing at the files channel's announcement anchor post. Recipients land on the anchor, scroll to find the platform-specific artifact they need. Link format: `t.me/c/<chat_id>/<msg>` for private channels (works for members), or `t.me/<username>/<msg>` if `FILES_CHANNEL_USERNAME` repo variable is set (works for everyone — useful if the files channel is later made public). Legacy telegram job in release.yml stays in source, dormant, gated on `vars.TELEGRAM_NOTIFY_ENABLED == 'true'` (default false). Comment updated to note the new workflow is the canonical path. If both are turned on at once, the main channel gets two posts per release. Tested manually for syntax + caption rendering — actual SHA-256 values will appear on the next workflow_dispatch run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7e5e2c7 commit 0669b93

3 files changed

Lines changed: 182 additions & 33 deletions

File tree

.github/scripts/telegram_publish_files.py

Lines changed: 147 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import argparse
20+
import hashlib
2021
import os
2122
import sys
2223
import time
@@ -193,6 +194,16 @@ def html_escape(s: str) -> str:
193194
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
194195

195196

197+
def sha256_hex(path: Path) -> str:
198+
"""Stream-hash the file in 1 MiB chunks. Avoids loading 40+ MB APKs
199+
into RAM twice (once for hashing, once for upload)."""
200+
h = hashlib.sha256()
201+
with path.open("rb") as f:
202+
for chunk in iter(lambda: f.read(1 << 20), b""):
203+
h.update(chunk)
204+
return h.hexdigest()
205+
206+
196207
def post_file(
197208
bot_token: str,
198209
chat_id: str,
@@ -201,12 +212,27 @@ def post_file(
201212
hashtag: str,
202213
) -> bool:
203214
"""Post one file. If too big, split + post each part. Returns True
204-
on success of all parts, False on any failure."""
215+
on success of all parts, False on any failure.
216+
217+
Each caption ends with the file's SHA-256 in hex under a Persian
218+
"تایید اصالت" (authenticity verification) label, so recipients can
219+
`sha256sum <file>` after download and confirm it matches what the
220+
channel posted — defends against modified copies if the channel is
221+
ever compromised or relayed through a third party."""
205222
size = file_path.stat().st_size
223+
224+
# Compute the original-file hash regardless of whether we'll chunk
225+
# it. For chunked uploads, every part's caption shows this hash so
226+
# the user can verify the full file once reassembled with `cat`.
227+
print(f" hashing {file_path.name}...", flush=True)
228+
full_sha = sha256_hex(file_path)
229+
206230
if size <= CHUNK_LIMIT_BYTES:
207231
caption = (
208232
f"<b>{html_escape(base_caption)}</b>\n"
209233
f"<code>{html_escape(file_path.name)}</code>\n"
234+
f"\nتایید اصالت (SHA-256):\n"
235+
f"<code>{full_sha}</code>\n"
210236
f"\n{hashtag}"
211237
)
212238
print(f" uploading {file_path.name} ({size / 1_048_576:.1f} MB)...", flush=True)
@@ -241,12 +267,19 @@ def post_file(
241267
n = len(parts)
242268
all_ok = True
243269
for idx, part_path in enumerate(parts, start=1):
270+
# Hash the individual part too — lets the user verify each
271+
# downloaded chunk before bothering to reassemble.
272+
part_sha = sha256_hex(part_path)
244273
part_caption = (
245274
f"<b>{html_escape(base_caption)} — قسمت {idx}/{n}</b>\n"
246275
f"<code>{html_escape(part_path.name)}</code>\n"
247276
f"\nبرای بازسازی فایل اصلی:\n"
248277
f"<code>cat {html_escape(file_path.name)}.part_* &gt; "
249278
f"{html_escape(file_path.name)}</code>\n"
279+
f"\nتایید اصالت این قسمت (SHA-256):\n"
280+
f"<code>{part_sha}</code>\n"
281+
f"\nتایید اصالت فایل کامل پس از بازسازی (SHA-256):\n"
282+
f"<code>{full_sha}</code>\n"
250283
f"\n{hashtag}"
251284
)
252285
psize = part_path.stat().st_size
@@ -281,6 +314,78 @@ def post_file(
281314
return all_ok
282315

283316

317+
def files_channel_post_link(chat_id: str, message_id: int) -> str:
318+
"""Build a `t.me` link to a specific message in the files channel.
319+
320+
For private supergroups/channels (negative ID with `-100` prefix),
321+
Telegram exposes posts at `https://t.me/c/<id>/<msg>` where `<id>`
322+
is the chat ID with the `-100` stripped. This link works for users
323+
who are members of the channel.
324+
325+
If `FILES_CHANNEL_USERNAME` is set in env (e.g. `mhrv_files`), uses
326+
the public-channel form `https://t.me/<username>/<msg>` instead,
327+
which is clickable for everyone."""
328+
username = os.environ.get("FILES_CHANNEL_USERNAME", "").strip().lstrip("@")
329+
if username:
330+
return f"https://t.me/{username}/{message_id}"
331+
cid = chat_id
332+
if cid.startswith("-100"):
333+
cid = cid[4:]
334+
elif cid.startswith("-"):
335+
cid = cid[1:]
336+
return f"https://t.me/c/{cid}/{message_id}"
337+
338+
339+
def post_main_channel_pointer(
340+
bot_token: str,
341+
main_chat_id: str,
342+
files_channel_link: str,
343+
version: str,
344+
hashtag: str,
345+
) -> bool:
346+
"""Post a short cross-link to the main announcement channel pointing
347+
at the anchor post in the files channel. Replaces the previous
348+
behaviour of posting the universal APK + full changelog directly
349+
to the main channel — the main channel becomes a discovery surface
350+
while the files channel hosts the actual artifacts.
351+
"""
352+
text = (
353+
f"<b>📦 mhrv-rs v{html_escape(version)} منتشر شد</b>\n"
354+
f"\nبرای دانلود فایل‌ها (Android، Windows، macOS، Linux و ...) "
355+
f"به کانال فایل‌ها مراجعه کنید:\n"
356+
f"\n👉 <a href=\"{html_escape(files_channel_link)}\">"
357+
f"v{html_escape(version)} — همه فایل‌ها + SHA-256</a>\n"
358+
f"\n{hashtag}"
359+
)
360+
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
361+
data = urllib.parse.urlencode({
362+
"chat_id": main_chat_id,
363+
"text": text,
364+
"parse_mode": "HTML",
365+
"disable_web_page_preview": "false",
366+
}).encode()
367+
print(f" posting cross-link to main channel {main_chat_id}...", flush=True)
368+
try:
369+
with urllib.request.urlopen(
370+
urllib.request.Request(url, data=data, method="POST"), timeout=30
371+
) as resp:
372+
r = json.loads(resp.read().decode("utf-8"))
373+
if not r.get("ok"):
374+
print(f" !! main-channel post failed: {r}", flush=True)
375+
return False
376+
print(
377+
f" ok (message_id={r['result']['message_id']})", flush=True
378+
)
379+
return True
380+
except urllib.error.HTTPError as e:
381+
err_body = e.read().decode("utf-8", errors="replace")[:500]
382+
print(f" !! HTTP {e.code}: {err_body}", flush=True)
383+
return False
384+
except Exception as e:
385+
print(f" !! exception: {e}", flush=True)
386+
return False
387+
388+
284389
def main() -> int:
285390
parser = argparse.ArgumentParser(description=__doc__)
286391
parser.add_argument("--assets-dir", required=True, type=Path)
@@ -315,15 +420,18 @@ def main() -> int:
315420
print(f" - {f.name}")
316421
print()
317422

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.
423+
# Leading announcement in the files channel. Captured `message_id`
424+
# is the anchor that the main-channel cross-link points at — the
425+
# main channel doesn't carry files anymore, just a single message
426+
# saying "new release, click here." Recipients land on this anchor
427+
# and scroll down to see all the platform-specific files.
322428
announce = (
323429
f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>\n"
324430
f"\nفایل‌ها در ادامه به ترتیب پلتفرم ارسال می‌شن.\n"
431+
f"هر فایل با SHA-256 (تایید اصالت) همراه هست.\n"
325432
f"\n{args.hashtag}"
326433
)
434+
announce_msg_id: int | None = None
327435
try:
328436
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
329437
data = urllib.parse.urlencode({
@@ -339,9 +447,15 @@ def main() -> int:
339447
if not r.get("ok"):
340448
print(f" !! announcement failed: {r}", flush=True)
341449
else:
342-
print(f" announcement posted (message_id={r['result']['message_id']})", flush=True)
450+
announce_msg_id = r["result"]["message_id"]
451+
print(
452+
f" announcement posted (message_id={announce_msg_id})",
453+
flush=True,
454+
)
343455
except Exception as e:
344-
# Non-fatal: continue with file uploads even if announcement bombs.
456+
# Non-fatal for the file uploads, but cross-link to the main
457+
# channel below will be skipped — without the anchor message_id
458+
# there's nothing to point at.
345459
print(f" !! announcement exception: {e}", flush=True)
346460
time.sleep(INTER_UPLOAD_SLEEP_SECS)
347461

@@ -352,6 +466,32 @@ def main() -> int:
352466
if not ok:
353467
failures += 1
354468

469+
# Cross-link to the main announcement channel. Skipped if MAIN_CHAT_ID
470+
# is unset (development / private testing) or if the files-channel
471+
# announcement didn't post (no anchor to link to).
472+
main_chat_id = os.environ.get("MAIN_CHAT_ID", "").strip()
473+
if main_chat_id and announce_msg_id is not None:
474+
link = files_channel_post_link(chat_id, announce_msg_id)
475+
print()
476+
print(f"posting cross-link to main channel:")
477+
print(f" link: {link}")
478+
ok = post_main_channel_pointer(
479+
bot_token, main_chat_id, link, args.version, args.hashtag
480+
)
481+
if not ok:
482+
failures += 1
483+
elif main_chat_id and announce_msg_id is None:
484+
print()
485+
print(
486+
" !! MAIN_CHAT_ID is set but announcement message_id is None — "
487+
"skipping cross-link (no anchor to point at).",
488+
flush=True,
489+
)
490+
failures += 1
491+
else:
492+
print()
493+
print(" MAIN_CHAT_ID not set, skipping cross-link", flush=True)
494+
355495
print()
356496
if failures:
357497
print(f"DONE with {failures} failure(s) out of {len(files)}", flush=True)

.github/workflows/release.yml

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -828,35 +828,33 @@ jobs:
828828
# isn't gated by the same protection.
829829
git push origin HEAD:main
830830
831-
# Notify the Persian-speaking Telegram channel with the CI-built
832-
# Android APK + its sha256 + the per-version changelog from
833-
# `docs/changelog/v<tag>.md`.
831+
# ─────────── LEGACY — DORMANT BY DEFAULT ───────────
834832
#
835-
# Two Telegram API calls:
836-
# 1. sendDocument — APK file + a short caption (Telegram caps
837-
# captions at 1024 chars, and we have bigger changelogs than
838-
# that).
839-
# 2. sendMessage — full changelog as a reply to #1, Persian
840-
# quote-block first then English, same pattern as the
841-
# previous manual post. No emojis, as the user asked.
833+
# Posts the universal APK + per-version changelog to the **main**
834+
# Telegram channel as one big sendDocument + sendMessage pair.
842835
#
843-
# Needs two repo secrets:
844-
# TELEGRAM_BOT_TOKEN — bot the channel admits as poster
845-
# TELEGRAM_CHAT_ID — numeric chat id (starts with -100...)
846-
# Missing either => the whole job is skipped (not failed) so a
847-
# forker who hasn't set up a Telegram channel gets a clean release.
836+
# Superseded as of v1.8.0+ by `.github/workflows/telegram-publish-files.yml`,
837+
# which posts each platform's artifact individually to the **files**
838+
# channel (with SHA-256 captions) and then a single cross-link
839+
# message to the main channel pointing at the files-channel anchor.
840+
#
841+
# This job stays in the source tree, dormant, in case we ever want
842+
# to revert to the bundled-changelog-on-main-channel pattern (or
843+
# use both at once during a transition). To turn it back on:
844+
#
845+
# gh variable set TELEGRAM_NOTIFY_ENABLED --body true
846+
#
847+
# Note: with the new workflow active too, that produces TWO posts
848+
# to the main channel per release (the legacy APK+changelog *and*
849+
# the new cross-link). Pick one.
850+
#
851+
# Default state is disabled.
848852
telegram:
849853
needs: [android, release]
850854
runs-on: ubuntu-latest
851855
# Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
852-
# OFF — the job skips silently unless the variable is set to the
853-
# literal string "true". Toggle via:
854-
#
855-
# gh variable set TELEGRAM_NOTIFY_ENABLED --body true
856-
# gh variable set TELEGRAM_NOTIFY_ENABLED --body false
857-
#
858-
# Keeping the machinery (script + secrets) in place so flipping
859-
# the switch back on is a one-liner, not a workflow edit.
856+
# off — the job skips silently unless the variable is set to the
857+
# literal string "true".
860858
if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
861859
steps:
862860
- uses: actions/checkout@v4

.github/workflows/telegram-publish-files.yml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,23 @@ jobs:
9999
- name: Publish files to Telegram channel
100100
env:
101101
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
102-
# The target channel — supergroup-style negative ID. Hard-coded
102+
# The files channel — supergroup-style negative ID, hard-coded
103103
# rather than templated as a repo variable because there's only
104104
# ever one of these and putting it in source makes the workflow
105-
# auditable. The bot token (`secrets.TELEGRAM_BOT_TOKEN`)
106-
# already has post permissions on this channel.
105+
# auditable. The bot token already has post permissions there.
107106
CHAT_ID: '-1003966234444'
107+
# The main announcement channel. Receives a single cross-link
108+
# message per release pointing at the file-channel anchor post,
109+
# instead of the previous behaviour of attaching the universal
110+
# APK + full changelog. Sourced from the same secret the
111+
# legacy `telegram` job in release.yml used.
112+
MAIN_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
113+
# Optional: if the files channel later gets a public username,
114+
# set the repo variable `FILES_CHANNEL_USERNAME` (without the
115+
# `@`) so the cross-link uses the prettier `t.me/<name>/<msg>`
116+
# form instead of `t.me/c/<id>/<msg>` (which only resolves for
117+
# channel members).
118+
FILES_CHANNEL_USERNAME: ${{ vars.FILES_CHANNEL_USERNAME }}
108119
run: |
109120
if [ -z "${BOT_TOKEN:-}" ]; then
110121
echo "::error::TELEGRAM_BOT_TOKEN not set; can't publish"

0 commit comments

Comments
 (0)