mirror of
https://github.com/therealaleph/MasterHttpRelayVPN-RUST.git
synced 2026-05-18 07:44:47 +03:00
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>
This commit is contained in:
@@ -17,6 +17,7 @@ for the same version (the channel will get duplicate posts).
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -193,6 +194,16 @@ def html_escape(s: str) -> str:
|
|||||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_hex(path: Path) -> str:
|
||||||
|
"""Stream-hash the file in 1 MiB chunks. Avoids loading 40+ MB APKs
|
||||||
|
into RAM twice (once for hashing, once for upload)."""
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def post_file(
|
def post_file(
|
||||||
bot_token: str,
|
bot_token: str,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -201,12 +212,27 @@ def post_file(
|
|||||||
hashtag: str,
|
hashtag: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Post one file. If too big, split + post each part. Returns True
|
"""Post one file. If too big, split + post each part. Returns True
|
||||||
on success of all parts, False on any failure."""
|
on success of all parts, False on any failure.
|
||||||
|
|
||||||
|
Each caption ends with the file's SHA-256 in hex under a Persian
|
||||||
|
"تایید اصالت" (authenticity verification) label, so recipients can
|
||||||
|
`sha256sum <file>` after download and confirm it matches what the
|
||||||
|
channel posted — defends against modified copies if the channel is
|
||||||
|
ever compromised or relayed through a third party."""
|
||||||
size = file_path.stat().st_size
|
size = file_path.stat().st_size
|
||||||
|
|
||||||
|
# Compute the original-file hash regardless of whether we'll chunk
|
||||||
|
# it. For chunked uploads, every part's caption shows this hash so
|
||||||
|
# the user can verify the full file once reassembled with `cat`.
|
||||||
|
print(f" hashing {file_path.name}...", flush=True)
|
||||||
|
full_sha = sha256_hex(file_path)
|
||||||
|
|
||||||
if size <= CHUNK_LIMIT_BYTES:
|
if size <= CHUNK_LIMIT_BYTES:
|
||||||
caption = (
|
caption = (
|
||||||
f"<b>{html_escape(base_caption)}</b>\n"
|
f"<b>{html_escape(base_caption)}</b>\n"
|
||||||
f"<code>{html_escape(file_path.name)}</code>\n"
|
f"<code>{html_escape(file_path.name)}</code>\n"
|
||||||
|
f"\nتایید اصالت (SHA-256):\n"
|
||||||
|
f"<code>{full_sha}</code>\n"
|
||||||
f"\n{hashtag}"
|
f"\n{hashtag}"
|
||||||
)
|
)
|
||||||
print(f" uploading {file_path.name} ({size / 1_048_576:.1f} MB)...", flush=True)
|
print(f" uploading {file_path.name} ({size / 1_048_576:.1f} MB)...", flush=True)
|
||||||
@@ -241,12 +267,19 @@ def post_file(
|
|||||||
n = len(parts)
|
n = len(parts)
|
||||||
all_ok = True
|
all_ok = True
|
||||||
for idx, part_path in enumerate(parts, start=1):
|
for idx, part_path in enumerate(parts, start=1):
|
||||||
|
# Hash the individual part too — lets the user verify each
|
||||||
|
# downloaded chunk before bothering to reassemble.
|
||||||
|
part_sha = sha256_hex(part_path)
|
||||||
part_caption = (
|
part_caption = (
|
||||||
f"<b>{html_escape(base_caption)} — قسمت {idx}/{n}</b>\n"
|
f"<b>{html_escape(base_caption)} — قسمت {idx}/{n}</b>\n"
|
||||||
f"<code>{html_escape(part_path.name)}</code>\n"
|
f"<code>{html_escape(part_path.name)}</code>\n"
|
||||||
f"\nبرای بازسازی فایل اصلی:\n"
|
f"\nبرای بازسازی فایل اصلی:\n"
|
||||||
f"<code>cat {html_escape(file_path.name)}.part_* > "
|
f"<code>cat {html_escape(file_path.name)}.part_* > "
|
||||||
f"{html_escape(file_path.name)}</code>\n"
|
f"{html_escape(file_path.name)}</code>\n"
|
||||||
|
f"\nتایید اصالت این قسمت (SHA-256):\n"
|
||||||
|
f"<code>{part_sha}</code>\n"
|
||||||
|
f"\nتایید اصالت فایل کامل پس از بازسازی (SHA-256):\n"
|
||||||
|
f"<code>{full_sha}</code>\n"
|
||||||
f"\n{hashtag}"
|
f"\n{hashtag}"
|
||||||
)
|
)
|
||||||
psize = part_path.stat().st_size
|
psize = part_path.stat().st_size
|
||||||
@@ -281,6 +314,78 @@ def post_file(
|
|||||||
return all_ok
|
return all_ok
|
||||||
|
|
||||||
|
|
||||||
|
def files_channel_post_link(chat_id: str, message_id: int) -> str:
|
||||||
|
"""Build a `t.me` link to a specific message in the files channel.
|
||||||
|
|
||||||
|
For private supergroups/channels (negative ID with `-100` prefix),
|
||||||
|
Telegram exposes posts at `https://t.me/c/<id>/<msg>` where `<id>`
|
||||||
|
is the chat ID with the `-100` stripped. This link works for users
|
||||||
|
who are members of the channel.
|
||||||
|
|
||||||
|
If `FILES_CHANNEL_USERNAME` is set in env (e.g. `mhrv_files`), uses
|
||||||
|
the public-channel form `https://t.me/<username>/<msg>` instead,
|
||||||
|
which is clickable for everyone."""
|
||||||
|
username = os.environ.get("FILES_CHANNEL_USERNAME", "").strip().lstrip("@")
|
||||||
|
if username:
|
||||||
|
return f"https://t.me/{username}/{message_id}"
|
||||||
|
cid = chat_id
|
||||||
|
if cid.startswith("-100"):
|
||||||
|
cid = cid[4:]
|
||||||
|
elif cid.startswith("-"):
|
||||||
|
cid = cid[1:]
|
||||||
|
return f"https://t.me/c/{cid}/{message_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def post_main_channel_pointer(
|
||||||
|
bot_token: str,
|
||||||
|
main_chat_id: str,
|
||||||
|
files_channel_link: str,
|
||||||
|
version: str,
|
||||||
|
hashtag: str,
|
||||||
|
) -> bool:
|
||||||
|
"""Post a short cross-link to the main announcement channel pointing
|
||||||
|
at the anchor post in the files channel. Replaces the previous
|
||||||
|
behaviour of posting the universal APK + full changelog directly
|
||||||
|
to the main channel — the main channel becomes a discovery surface
|
||||||
|
while the files channel hosts the actual artifacts.
|
||||||
|
"""
|
||||||
|
text = (
|
||||||
|
f"<b>📦 mhrv-rs v{html_escape(version)} منتشر شد</b>\n"
|
||||||
|
f"\nبرای دانلود فایلها (Android، Windows، macOS، Linux و ...) "
|
||||||
|
f"به کانال فایلها مراجعه کنید:\n"
|
||||||
|
f"\n👉 <a href=\"{html_escape(files_channel_link)}\">"
|
||||||
|
f"v{html_escape(version)} — همه فایلها + SHA-256</a>\n"
|
||||||
|
f"\n{hashtag}"
|
||||||
|
)
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
|
data = urllib.parse.urlencode({
|
||||||
|
"chat_id": main_chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "HTML",
|
||||||
|
"disable_web_page_preview": "false",
|
||||||
|
}).encode()
|
||||||
|
print(f" posting cross-link to main channel {main_chat_id}...", flush=True)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(
|
||||||
|
urllib.request.Request(url, data=data, method="POST"), timeout=30
|
||||||
|
) as resp:
|
||||||
|
r = json.loads(resp.read().decode("utf-8"))
|
||||||
|
if not r.get("ok"):
|
||||||
|
print(f" !! main-channel post failed: {r}", flush=True)
|
||||||
|
return False
|
||||||
|
print(
|
||||||
|
f" ok (message_id={r['result']['message_id']})", flush=True
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err_body = e.read().decode("utf-8", errors="replace")[:500]
|
||||||
|
print(f" !! HTTP {e.code}: {err_body}", flush=True)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f" !! exception: {e}", flush=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--assets-dir", required=True, type=Path)
|
parser.add_argument("--assets-dir", required=True, type=Path)
|
||||||
@@ -315,15 +420,18 @@ def main() -> int:
|
|||||||
print(f" - {f.name}")
|
print(f" - {f.name}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# Optional: a leading announcement message that anchors the file
|
# Leading announcement in the files channel. Captured `message_id`
|
||||||
# batch. Posted as a regular sendMessage so it shows above the file
|
# is the anchor that the main-channel cross-link points at — the
|
||||||
# group in the channel and gives recipients a single hashtag link
|
# main channel doesn't carry files anymore, just a single message
|
||||||
# to find this release later.
|
# saying "new release, click here." Recipients land on this anchor
|
||||||
|
# and scroll down to see all the platform-specific files.
|
||||||
announce = (
|
announce = (
|
||||||
f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>\n"
|
f"<b>📦 mhrv-rs {html_escape('v' + args.version)} منتشر شد</b>\n"
|
||||||
f"\nفایلها در ادامه به ترتیب پلتفرم ارسال میشن.\n"
|
f"\nفایلها در ادامه به ترتیب پلتفرم ارسال میشن.\n"
|
||||||
|
f"هر فایل با SHA-256 (تایید اصالت) همراه هست.\n"
|
||||||
f"\n{args.hashtag}"
|
f"\n{args.hashtag}"
|
||||||
)
|
)
|
||||||
|
announce_msg_id: int | None = None
|
||||||
try:
|
try:
|
||||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
data = urllib.parse.urlencode({
|
data = urllib.parse.urlencode({
|
||||||
@@ -339,9 +447,15 @@ def main() -> int:
|
|||||||
if not r.get("ok"):
|
if not r.get("ok"):
|
||||||
print(f" !! announcement failed: {r}", flush=True)
|
print(f" !! announcement failed: {r}", flush=True)
|
||||||
else:
|
else:
|
||||||
print(f" announcement posted (message_id={r['result']['message_id']})", flush=True)
|
announce_msg_id = r["result"]["message_id"]
|
||||||
|
print(
|
||||||
|
f" announcement posted (message_id={announce_msg_id})",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Non-fatal: continue with file uploads even if announcement bombs.
|
# Non-fatal for the file uploads, but cross-link to the main
|
||||||
|
# channel below will be skipped — without the anchor message_id
|
||||||
|
# there's nothing to point at.
|
||||||
print(f" !! announcement exception: {e}", flush=True)
|
print(f" !! announcement exception: {e}", flush=True)
|
||||||
time.sleep(INTER_UPLOAD_SLEEP_SECS)
|
time.sleep(INTER_UPLOAD_SLEEP_SECS)
|
||||||
|
|
||||||
@@ -352,6 +466,32 @@ def main() -> int:
|
|||||||
if not ok:
|
if not ok:
|
||||||
failures += 1
|
failures += 1
|
||||||
|
|
||||||
|
# Cross-link to the main announcement channel. Skipped if MAIN_CHAT_ID
|
||||||
|
# is unset (development / private testing) or if the files-channel
|
||||||
|
# announcement didn't post (no anchor to link to).
|
||||||
|
main_chat_id = os.environ.get("MAIN_CHAT_ID", "").strip()
|
||||||
|
if main_chat_id and announce_msg_id is not None:
|
||||||
|
link = files_channel_post_link(chat_id, announce_msg_id)
|
||||||
|
print()
|
||||||
|
print(f"posting cross-link to main channel:")
|
||||||
|
print(f" link: {link}")
|
||||||
|
ok = post_main_channel_pointer(
|
||||||
|
bot_token, main_chat_id, link, args.version, args.hashtag
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
failures += 1
|
||||||
|
elif main_chat_id and announce_msg_id is None:
|
||||||
|
print()
|
||||||
|
print(
|
||||||
|
" !! MAIN_CHAT_ID is set but announcement message_id is None — "
|
||||||
|
"skipping cross-link (no anchor to point at).",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
failures += 1
|
||||||
|
else:
|
||||||
|
print()
|
||||||
|
print(" MAIN_CHAT_ID not set, skipping cross-link", flush=True)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if failures:
|
if failures:
|
||||||
print(f"DONE with {failures} failure(s) out of {len(files)}", flush=True)
|
print(f"DONE with {failures} failure(s) out of {len(files)}", flush=True)
|
||||||
|
|||||||
@@ -828,35 +828,33 @@ jobs:
|
|||||||
# isn't gated by the same protection.
|
# isn't gated by the same protection.
|
||||||
git push origin HEAD:main
|
git push origin HEAD:main
|
||||||
|
|
||||||
# Notify the Persian-speaking Telegram channel with the CI-built
|
# ─────────── LEGACY — DORMANT BY DEFAULT ───────────
|
||||||
# Android APK + its sha256 + the per-version changelog from
|
|
||||||
# `docs/changelog/v<tag>.md`.
|
|
||||||
#
|
#
|
||||||
# Two Telegram API calls:
|
# Posts the universal APK + per-version changelog to the **main**
|
||||||
# 1. sendDocument — APK file + a short caption (Telegram caps
|
# Telegram channel as one big sendDocument + sendMessage pair.
|
||||||
# captions at 1024 chars, and we have bigger changelogs than
|
|
||||||
# that).
|
|
||||||
# 2. sendMessage — full changelog as a reply to #1, Persian
|
|
||||||
# quote-block first then English, same pattern as the
|
|
||||||
# previous manual post. No emojis, as the user asked.
|
|
||||||
#
|
#
|
||||||
# Needs two repo secrets:
|
# Superseded as of v1.8.0+ by `.github/workflows/telegram-publish-files.yml`,
|
||||||
# TELEGRAM_BOT_TOKEN — bot the channel admits as poster
|
# which posts each platform's artifact individually to the **files**
|
||||||
# TELEGRAM_CHAT_ID — numeric chat id (starts with -100...)
|
# channel (with SHA-256 captions) and then a single cross-link
|
||||||
# Missing either => the whole job is skipped (not failed) so a
|
# message to the main channel pointing at the files-channel anchor.
|
||||||
# forker who hasn't set up a Telegram channel gets a clean release.
|
#
|
||||||
|
# This job stays in the source tree, dormant, in case we ever want
|
||||||
|
# to revert to the bundled-changelog-on-main-channel pattern (or
|
||||||
|
# use both at once during a transition). To turn it back on:
|
||||||
|
#
|
||||||
|
# gh variable set TELEGRAM_NOTIFY_ENABLED --body true
|
||||||
|
#
|
||||||
|
# Note: with the new workflow active too, that produces TWO posts
|
||||||
|
# to the main channel per release (the legacy APK+changelog *and*
|
||||||
|
# the new cross-link). Pick one.
|
||||||
|
#
|
||||||
|
# Default state is disabled.
|
||||||
telegram:
|
telegram:
|
||||||
needs: [android, release]
|
needs: [android, release]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
|
# Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
|
||||||
# OFF — the job skips silently unless the variable is set to the
|
# off — the job skips silently unless the variable is set to the
|
||||||
# literal string "true". Toggle via:
|
# literal string "true".
|
||||||
#
|
|
||||||
# gh variable set TELEGRAM_NOTIFY_ENABLED --body true
|
|
||||||
# gh variable set TELEGRAM_NOTIFY_ENABLED --body false
|
|
||||||
#
|
|
||||||
# Keeping the machinery (script + secrets) in place so flipping
|
|
||||||
# the switch back on is a one-liner, not a workflow edit.
|
|
||||||
if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
|
if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -99,12 +99,23 @@ jobs:
|
|||||||
- name: Publish files to Telegram channel
|
- name: Publish files to Telegram channel
|
||||||
env:
|
env:
|
||||||
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||||
# The target channel — supergroup-style negative ID. Hard-coded
|
# The files channel — supergroup-style negative ID, hard-coded
|
||||||
# rather than templated as a repo variable because there's only
|
# rather than templated as a repo variable because there's only
|
||||||
# ever one of these and putting it in source makes the workflow
|
# ever one of these and putting it in source makes the workflow
|
||||||
# auditable. The bot token (`secrets.TELEGRAM_BOT_TOKEN`)
|
# auditable. The bot token already has post permissions there.
|
||||||
# already has post permissions on this channel.
|
|
||||||
CHAT_ID: '-1003966234444'
|
CHAT_ID: '-1003966234444'
|
||||||
|
# The main announcement channel. Receives a single cross-link
|
||||||
|
# message per release pointing at the file-channel anchor post,
|
||||||
|
# instead of the previous behaviour of attaching the universal
|
||||||
|
# APK + full changelog. Sourced from the same secret the
|
||||||
|
# legacy `telegram` job in release.yml used.
|
||||||
|
MAIN_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
|
||||||
|
# Optional: if the files channel later gets a public username,
|
||||||
|
# set the repo variable `FILES_CHANNEL_USERNAME` (without the
|
||||||
|
# `@`) so the cross-link uses the prettier `t.me/<name>/<msg>`
|
||||||
|
# form instead of `t.me/c/<id>/<msg>` (which only resolves for
|
||||||
|
# channel members).
|
||||||
|
FILES_CHANNEL_USERNAME: ${{ vars.FILES_CHANNEL_USERNAME }}
|
||||||
run: |
|
run: |
|
||||||
if [ -z "${BOT_TOKEN:-}" ]; then
|
if [ -z "${BOT_TOKEN:-}" ]; then
|
||||||
echo "::error::TELEGRAM_BOT_TOKEN not set; can't publish"
|
echo "::error::TELEGRAM_BOT_TOKEN not set; can't publish"
|
||||||
|
|||||||
Reference in New Issue
Block a user