feat: v1.9.4 — exit node for ChatGPT/Claude/Grok + drop duplicate Telegram post

Two changes addressing user-reported issues today:

1. Exit-node feature ported from upstream masterking32@464a6e1d, with
   hardening. Cloudflare-protected sites (chatgpt.com, claude.ai,
   grok.com, x.com, openai.com) flag Google datacenter IPs as bots and
   return Turnstile / CAPTCHA / 502 challenges. Apps Script's UrlFetchApp
   exits from those IPs, so v1.9.3 surfaces these as "Relay error: json:
   key must be a string..." with no apps_script-mode workaround.

   Now a small TypeScript HTTP endpoint (assets/exit_node/valtown.ts)
   deployed on val.town / Deno Deploy sits between Apps Script and the
   destination. Chain: client → Apps Script (Google IP) → val.town
   (non-Google IP) → destination. Destination sees val.town's IP, no
   CF challenge.

   Config:
     "exit_node": {
       "enabled": true,
       "relay_url": "https://...web.val.run",
       "psk": "<openssl rand -hex 32>",
       "mode": "selective",
       "hosts": ["chatgpt.com", "claude.ai", "x.com", "grok.com", "openai.com"]
     }

   Hardening over upstream: PSK fail-closed if still placeholder (fresh
   deploy can't be open relay), loop guard (refuses fetch of own host),
   explicit 503 on misconfigured. Fallback to direct Apps Script on exit
   node failure (CF-affected sites fail, others keep working). Setup
   docs in English + Persian at assets/exit_node/README*.md. Example
   config at config.exit-node.example.json.

2. Removed the legacy `telegram` job from release.yml. With
   TELEGRAM_NOTIFY_ENABLED repo var set to true, every release was
   producing two duplicate APK posts on the main Telegram channel: the
   old bundled-APK-on-main job AND the newer per-file files-channel
   posts (telegram-publish-files.yml). Only the per-file flow is wanted.
   Legacy job and its helper telegram_release_notify.py are gone.
   Recoverable from git log if anyone needs the bundled pattern back.

169 mhrv-rs lib tests + 33 tunnel-node tests + UI build clean.
This commit is contained in:
therealaleph
2026-05-01 11:52:32 +03:00
parent d65759d8b8
commit 4aac9a793f
12 changed files with 1070 additions and 494 deletions
-392
View File
@@ -1,392 +0,0 @@
#!/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 <blockquote> 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
`<file` as "read from file" and `@file` as "upload file". Our HTML
captions contain literal `<b>` 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 _strip_leading_comments(body: str) -> str:
"""Strip leading HTML comment blocks (single- or multi-line) from `body`.
The changelog template uses `<!-- ... -->` to document the format for
editors; we don't want those echoed to Telegram or the GitHub Release
page. The `(?:...)+` quantifier eats N consecutive comments separated
only by whitespace, so a stub with both a format-docs comment and a
TODO comment is cleaned in one pass. `re.S` makes `.` cross newlines
for multi-line `<!--\\n...\\n-->` blocks.
The matching regex is also used inline by .github/workflows/release.yml
to compose the GitHub Release body — keep them in sync if you change
one. Run `python -m doctest telegram_release_notify.py -v` to check.
>>> _strip_leading_comments("<!-- header -->\\nbody")
'body'
>>> _strip_leading_comments("<!-- a -->\\n<!-- b -->\\nbody")
'body'
>>> _strip_leading_comments("<!--\\nmulti\\nline\\n-->\\nbody")
'body'
>>> _strip_leading_comments("<!-- a -->\\n\\n<!-- b -->\\n\\nbody")
'body'
>>> _strip_leading_comments("body without comments")
'body without comments'
>>> _strip_leading_comments("body\\n<!-- mid-file comment -->\\nmore")
'body\\n<!-- mid-file comment -->\\nmore'
"""
return re.sub(r"^\s*(?:<!--.*?-->\s*)+", "", body, count=1, flags=re.S)
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 = _strip_leading_comments(p.read_text(encoding="utf-8"))
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()
# Telegram caption hard-cap is 1024 chars. The fixed parts of our caption
# (title + SHA hash + two-link footer with their preambles) sum to roughly
# 470 chars on a typical version string. That leaves ~550 chars for the
# release-note section before we'd start losing the trailing release URL.
# Keep the budget conservative so a long version string or a slightly
# longer hash representation doesn't push us over.
CAPTION_FA_NOTE_BUDGET = 500
def _md_links_to_html(text: str) -> str:
"""Convert `[label](url)` markdown links to `<a href="url">label</a>`.
Telegram's HTML parse mode renders `<a>` as clickable but treats
markdown verbatim, so an unconverted `[#160](https://…)` appears as
that literal string in the channel post — both ugly and wasteful of
caption budget. The HTML form is shorter visually (`#160` vs the
full URL), still clickable, and counts the same toward Telegram's
1024-char limit. Inline `code` (`backtick-quoted`) is also
translated to `<code>…</code>` since markdown backticks render
literally too.
"""
text = re.sub(
r"\[([^\]]+)\]\(([^)]+)\)",
lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>',
text,
)
text = re.sub(r"`([^`\n]+)`", r"<code>\1</code>", text)
# Bold (**…**) is rare in our changelog but happens — convert to <b>.
text = re.sub(r"\*\*([^*\n]+)\*\*", r"<b>\1</b>", text)
return text
def _extract_headlines(fa_section: str) -> str:
"""For each `• …: …` bullet, keep the headline part and drop the
elaboration.
Our changelog convention writes each bullet as one of:
• headline: full explanation
• headline ([#NN](url)): full explanation
• headline (issue ref): full explanation
The headline is everything up to the `: ` (colon + space) that ends
the leading clause. Naively searching for the first `:` lands inside
`https:` URLs of the markdown link form — instead we search from the
end of the parenthesized-issue-ref (if any) for the first `: `, or
fall back to the first `: ` in the line.
Headlines stay on the FA caption; the explanation is preserved in
the docs/changelog/ file and (optionally) the reply-threaded message
posted via --with-changelog.
Returns a newline-joined string of `• <headline>` lines.
"""
headlines: list[str] = []
for line in fa_section.splitlines():
if not line.startswith(""):
continue
body = line[2:] # drop "• "
# Prefer cutting at "): " — the close of the parenthesized ref
# followed by the convention colon + space. That's our actual
# bullet structure and avoids the false-positive `https:` cut.
cut_idx = body.find("): ")
if cut_idx > 0:
headline = body[: cut_idx + 1] # keep the close paren
else:
# Fall back to ": " (colon + space) anywhere in the body.
# Adding the space requirement skips `https:` which is
# always followed by `/`.
cut_idx = body.find(": ")
headline = body[:cut_idx] if cut_idx > 0 else body
headlines.append(f"{headline.rstrip()}")
return "\n".join(headlines)
def build_caption_release_note(changelog_path: str) -> str:
"""Build the Persian "what's new" block for the Telegram caption.
Pulls the FA section of `docs/changelog/v<ver>.md`, extracts just
the bullet headlines (before the first `:` of each bullet) so the
note is compact, converts markdown links/code to Telegram HTML for
clickability, and wraps in a `<blockquote>`. Falls back to the full
FA section if the headlines extraction yields nothing (e.g. a
changelog that doesn't follow our `• headline: details` convention).
If the result still exceeds CAPTION_FA_NOTE_BUDGET, truncate at a
bullet boundary with a trailing `…`. In practice the headlines-only
form fits comfortably for any reasonable release note.
"""
fa, _en = parse_changelog(changelog_path)
if not fa:
return ""
headlines = _extract_headlines(fa)
note = headlines if headlines else fa.strip()
note = _md_links_to_html(note)
if len(note) > CAPTION_FA_NOTE_BUDGET:
truncated = note[:CAPTION_FA_NOTE_BUDGET]
last_bullet = truncated.rfind("\n")
if last_bullet > 0:
note = truncated[:last_bullet].rstrip() + "\n"
else:
note = truncated.rstrip() + ""
return f"<blockquote>{note}</blockquote>"
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<token>/<method>."""
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,
help="Path to docs/changelog/vX.Y.Z.md; only read when --with-changelog is passed.")
# Default: just the APK + short caption (title + SHA-256 + repo URL +
# release URL). The per-release Persian/English blockquote reply is
# opt-in via `--with-changelog` so routine releases don't flood the
# channel with bullet-point bodies. To re-enable for a specific tag:
# set the repo variable TELEGRAM_INCLUDE_CHANGELOG=true before pushing
# the tag (the workflow converts that into --with-changelog).
ap.add_argument("--with-changelog", action="store_true",
help="Include the Persian+English changelog as a reply-threaded message.")
# Dry-run lets you verify the rendered caption locally without hitting
# Telegram. Useful when changing the brief-release-note budget /
# truncation logic — print, eyeball, push.
ap.add_argument("--dry-run", action="store_true",
help="Render the caption and print it instead of posting. "
"Skips token/chat_id checks.")
args = ap.parse_args()
if not args.dry_run:
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
else:
token = ""
chat_id = ""
ver = args.version
sha = sha256_of(args.apk)
# Brief Persian release-note above the links. Pulled from the FA
# half of `docs/changelog/v<ver>.md` so each release auto-includes
# what's new without manual edits to this script. Truncated to fit
# Telegram's 1024-char caption budget alongside title + SHA + the
# two-link footer.
fa_note = build_caption_release_note(args.changelog)
# Caption structure requested by the repo owner:
# 1. Title + SHA-256 (as before)
# 2. Brief Persian "what's new" note (extracted from changelog)
# 3. Persian preamble labelling the repo link as
# "GitHub repo + full Persian guide"
# 4. Repo URL
# 5. Persian preamble labelling the release link as
# "this version's release — desktop/router builds live here"
# 6. Release URL
# Keeps total well under Telegram's 1024-char caption limit.
caption_parts = [
f"<b>mhrv-rs Android v{ver}</b>",
"",
f"SHA-256: <code>{sha}</code>",
]
if fa_note:
caption_parts.extend(["", fa_note])
caption_parts.extend([
"",
"مخزن گیتهاب + مطالعه راهنمای کامل فارسی:",
f"https://github.com/{args.repo}",
"",
"لینک به این نسخه جهت دریافت نسخه های مربوط به مودم و کامپیوتر:",
f"https://github.com/{args.repo}/releases/tag/v{ver}",
])
caption = "\n".join(caption_parts)
if args.dry_run:
print(f"--- DRY RUN: caption ({len(caption)} chars) ---")
print(caption)
print(f"--- END DRY RUN ---")
if args.with_changelog:
fa, en = parse_changelog(args.changelog)
print(f"\nWould reply with changelog "
f"(fa: {len(fa) if fa else 0} chars, "
f"en: {len(en) if en else 0} chars)")
return 0
doc_mid = send_document(token, chat_id, args.apk, caption)
print(f"sendDocument OK, message_id={doc_mid}")
if not args.with_changelog:
print("Changelog reply disabled (default). Pass --with-changelog to include.")
return 0
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"<blockquote>{fa}</blockquote>")
if en:
parts.append(f"<blockquote>{en}</blockquote>")
reply = "\n\n".join(parts)
send_reply(token, chat_id, reply, doc_mid)
print("Reply OK")
return 0
if __name__ == "__main__":
sys.exit(main())
+14 -100
View File
@@ -652,11 +652,9 @@ jobs:
{
echo 'body<<__RELEASE_BODY_EOF__'
# Strip leading HTML comment blocks (single-line OR multi-line)
# using the SAME regex as
# .github/scripts/telegram_release_notify.py:parse_changelog,
# so the GitHub Release page and the Telegram post agree on
# exactly what counts as "the leading comment block." Both
# also strip any leading whitespace/blank lines that follow.
# so the GitHub Release page sees only the body content, not
# the file-format header comment that every changelog has.
# Also strips any leading whitespace/blank lines that follow.
#
# Quoted heredoc (`<<'PY'`) so backticks/$ in the python
# snippet aren't shell-interpolated; CHANGELOG is passed in
@@ -828,99 +826,15 @@ jobs:
# isn't gated by the same protection.
git push origin HEAD:main
# ─────────── LEGACY — DORMANT BY DEFAULT ───────────
# The legacy `telegram` job that posted a universal APK + changelog
# bundle to the main Telegram channel was removed in v1.9.4. It was
# superseded by `.github/workflows/telegram-publish-files.yml` (per-
# platform per-file posts to the files channel + a single cross-link
# to the main channel). With both running together, every release
# produced a duplicate APK post on the main channel — the legacy
# bundled post AND the new cross-link.
#
# Posts the universal APK + per-version changelog to the **main**
# Telegram channel as one big sendDocument + sendMessage pair.
#
# Superseded as of v1.8.0+ by `.github/workflows/telegram-publish-files.yml`,
# which posts each platform's artifact individually to the **files**
# channel (with SHA-256 captions) and then a single cross-link
# message to the main channel pointing at the files-channel anchor.
#
# 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:
needs: [android, release]
runs-on: ubuntu-latest
# Gated on the repo variable `TELEGRAM_NOTIFY_ENABLED`. Default is
# off — the job skips silently unless the variable is set to the
# literal string "true".
if: ${{ vars.TELEGRAM_NOTIFY_ENABLED == 'true' && needs.android.result == 'success' }}
steps:
- uses: actions/checkout@v4
# Same retry pattern as the `release` job above — `actions/download-artifact@v4`
# has been flaking on this workflow with 5-retries-exhausted errors.
- name: Download universal APK (with retries)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p apk
for attempt in 1 2 3; do
if gh run download "${GITHUB_RUN_ID}" \
--name mhrv-rs-android-universal \
--dir apk \
--repo "${GITHUB_REPOSITORY}"; then
echo "downloaded universal APK on attempt $attempt"
ls -la apk/
exit 0
fi
echo "download attempt $attempt failed; retrying in 30s..."
sleep 30
done
echo "::error::failed to download universal APK after 3 attempts"
exit 1
- name: Post to Telegram
env:
BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
INCLUDE_CHANGELOG: ${{ vars.TELEGRAM_INCLUDE_CHANGELOG }}
# 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 `<b>` 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
VER="${{ inputs.version || github.ref_name }}"
VER="${VER#v}"
APK="apk/mhrv-rs-android-universal-v${VER}.apk"
if [ -z "${BOT_TOKEN:-}" ] || [ -z "${CHAT_ID:-}" ]; then
echo "::notice::TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID not set, skipping Telegram post"
exit 0
fi
if [ ! -f "$APK" ]; then
echo "::error::expected $APK to exist; got:"
ls -la apk/
exit 1
fi
# --with-changelog is opt-in. Default post is just the APK
# plus a short caption with the SHA-256, repo URL, and release
# URL — no long body. To include the Persian/English bullets
# for a specific tag, set the repo variable
# TELEGRAM_INCLUDE_CHANGELOG=true before pushing that tag.
INCLUDE_CHANGELOG_FLAG=""
if [ "${INCLUDE_CHANGELOG:-}" = "true" ]; then
INCLUDE_CHANGELOG_FLAG="--with-changelog"
fi
python3 .github/scripts/telegram_release_notify.py \
--apk "$APK" \
--version "$VER" \
--repo "$GITHUB_REPOSITORY" \
--changelog "docs/changelog/v${VER}.md" \
$INCLUDE_CHANGELOG_FLAG
# If you ever need to bring back the bundled-APK-on-main pattern, the
# commit history before v1.9.4 has the full job — `git log -- .github/workflows/release.yml`.
# The `TELEGRAM_NOTIFY_ENABLED` repo variable + `telegram_release_notify.py`
# script that the legacy job called are no longer referenced.