Skip to content

feat(bot): TELEGRAM_API_ROOT + TELEGRAM_PROXY_SECRET for reverse-proxy setups#92

Merged
grinev merged 1 commit into
grinev:mainfrom
avfirsov:reverse-proxy-apiroot-and-secret
May 10, 2026
Merged

feat(bot): TELEGRAM_API_ROOT + TELEGRAM_PROXY_SECRET for reverse-proxy setups#92
grinev merged 1 commit into
grinev:mainfrom
avfirsov:reverse-proxy-apiroot-and-secret

Conversation

@avfirsov
Copy link
Copy Markdown
Contributor

@avfirsov avfirsov commented Apr 21, 2026

Summary

Adds two new environment variables for routing Telegram Bot API calls and file downloads through a custom HTTPS reverse-proxy (e.g. nginx proxying api.telegram.org) with an optional shared-secret header.

  • TELEGRAM_API_ROOT — replaces https://api.telegram.org for both Bot API calls (via grammY's client.apiRoot) and file downloads. Defaults to empty → existing behaviour preserved.
  • TELEGRAM_PROXY_SECRET — if set, X-Proxy-Secret header is sent on every request so the reverse proxy can authorise callers. Defaults to empty.

Motivation

Corporate networks frequently block api.telegram.org at the DNS/IP level but allow the operator's own HTTPS endpoint. The existing TELEGRAM_PROXY_URL covers the SOCKS/HTTP-CONNECT forward-proxy case (tunnel TCP to api.telegram.org via a proxy). This PR covers the orthogonal reverse-proxy / URL-rewrite case: the bot connects directly to a reverse proxy that forwards to api.telegram.org server-side, with a shared secret gating access.

Typical nginx config on the operator's VPS:

server {
    listen 443 ssl http2;
    server_name tg-proxy.yourdomain.com;

    ssl_certificate     /etc/letsencrypt/live/tg-proxy.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/tg-proxy.yourdomain.com/privkey.pem;

    access_log off;  # token is in URL path — don't log
    client_max_body_size 50m;

    if ($http_x_proxy_secret != "your-shared-secret") { return 403; }

    location / {
        proxy_pass https://api.telegram.org;
        proxy_ssl_server_name on;
        proxy_set_header Host api.telegram.org;
    }
}

Bot-side .env:

TELEGRAM_API_ROOT=https://tg-proxy.yourdomain.com
TELEGRAM_PROXY_SECRET=your-shared-secret

Changes

  • src/config.ts — new telegram.apiRoot + telegram.proxySecret fields.
  • src/bot/index.ts — pass apiRoot into grammY's client options; inject X-Proxy-Secret via baseFetchConfig.headers. Composes with the existing TELEGRAM_PROXY_URL path.
  • src/bot/utils/file-download.ts — derive the file URL base from apiRoot; add the secret header to the fetch() call.
  • src/bot/handlers/voice.ts — same for the raw http.get() code path that downloads voice files (URL base + header).
  • .env.example — documents the two new variables with a short motivation.

Note on branch contents

This branch also carries a second commit that pins remark to 14.0.3 to fix an unrelated ERR_REQUIRE_ESM crash on Node 20 — I've opened that as a separate single-commit PR #93 so it can be reviewed and merged independently on its own merits. If #93 lands first, GitHub will auto-close that commit here and this PR will rebase cleanly. If you prefer I drop the remark commit from this branch while #93 is under review, let me know and I'll force-push.

Backward compatibility

Both new env vars default to empty. When unset, the bot behaves exactly as before: base URL stays https://api.telegram.org, no extra headers, no logs. Existing installs are unaffected.

Test plan

  • With neither var set — behavior unchanged, bot operates against api.telegram.org directly.
  • With TELEGRAM_API_ROOT set — Bot API calls go to the new root; file downloads use the new root.
  • With TELEGRAM_PROXY_SECRET set — header is attached to API calls AND to both file-download code paths.
  • End-to-end test against an nginx reverse-proxy + if ($http_x_proxy_secret != "suckit") { return 403; } — getMe/sendMessage/getFile all round-trip correctly.

Notes

Composition with TELEGRAM_PROXY_URL: the reverse-proxy and forward-proxy features are orthogonal and can be used together (forward proxy to reach the reverse proxy, though that's an unusual combination). No code path conflict.

@grinev
Copy link
Copy Markdown
Owner

grinev commented Apr 21, 2026

@avfirsov thanks for contribution! Please update readme file and add new env variables there. Also these two variables should be near TELEGRAM_PROXY_URL in env.example

avfirsov added a commit to avfirsov/opencode-telegram-bot that referenced this pull request May 9, 2026
Per @grinev review on PR grinev#92:

* Add both env vars to the Environment Variables table, in the Telegram
  block right after TELEGRAM_PROXY_URL.
* Add a new "Reverse Proxy (Optional)" subsection under Configuration
  with motivation, .env snippet, and a copy-pasteable nginx config.
* Resync README.md to current main (gains /detach, /ls, /mcps,
  TTS provider columns, OPENCODE_AUTO_RESTART_ENABLED, etc.) so the
  diff is clean against main.
@avfirsov
Copy link
Copy Markdown
Contributor Author

avfirsov commented May 9, 2026

@grinev thanks for the review — addressed both points and rebased on current main:

Review feedback

  • .env.example — moved the TELEGRAM_API_ROOT / TELEGRAM_PROXY_SECRET block right after TELEGRAM_PROXY_URL (it now sits in the Telegram block instead of dangling at the end of the file). diff
  • README.md — both vars are now in the Environment Variables table next to TELEGRAM_PROXY_URL, plus a new Reverse Proxy (Optional) subsection under Configuration with motivation, an .env snippet, and a copy-pasteable nginx config (essentially the example from the PR description, lifted into the README so it's discoverable). diff

Drive-by — rebased the branch

Branch was dirty because:

  • fix(deps): pin remark to 14.0.3 via overrides (fix ERR_REQUIRE_ESM on Node 20) #93 (the remark pin) was merged in the meantime, so overrides.remark is dead — telegram-markdown-v2 is gone from main's deps and ERR_REQUIRE_ESM is no longer reachable. Dropped the overrides block.
  • main advanced significantly (v0.17.0 → v0.20.1, added OPENCODE_AUTO_RESTART_ENABLED, TRACK_BACKGROUND_SESSIONS, Google Cloud TTS, /ls /detach /mcps commands, etc.). Resynced package.json, .env.example, README.md to current main so the diff is clean and only contains the reverse-proxy-related additions.

Source files (src/config.ts, src/bot/index.ts, src/bot/handlers/voice.ts, src/bot/utils/file-download.ts) didn't need any rebase — main hadn't touched the lines I patched, so the original implementation applies to current main unchanged.

PR is now mergeable and CI (Lint, Build, Test) is green on the rebased head. Let me know if you'd like the description updated or anything else tweaked.

@grinev
Copy link
Copy Markdown
Owner

grinev commented May 9, 2026

@avfirsov thanks for the update. Could you please try to apply changes on the clean main branch?

Right now the branch still diverges from an old base commit, so the PR diff includes changes that are already present in main. This makes the review hard to follow and also mixes the reverse-proxy changes with unrelated history.

After a clean rebase, the PR should ideally show only the TELEGRAM_API_ROOT / TELEGRAM_PROXY_SECRET changes and the related docs updates.

@avfirsov avfirsov force-pushed the reverse-proxy-apiroot-and-secret branch from 55a0627 to f07b968 Compare May 10, 2026 07:46
@avfirsov
Copy link
Copy Markdown
Contributor Author

@grinev fair point — done. Force-pushed a clean rebase: branch is now exactly one commit (f07b968) on top of current main (894d041).

Diff against main is now strictly the reverse-proxy feature:

.env.example                     +10 -0
README.md                        +40 -0
src/bot/handlers/voice.ts         +7 -2
src/bot/index.ts                 +27 -0
src/bot/utils/file-download.ts   +16 -2
src/config.ts                     +2 -0
6 files, +102 -4

No more package.json churn, no more historical noise from the old base — only the two new env vars, their wiring through config.ts / index.ts / file-download.ts / voice.ts, and the .env.example + README updates from the previous round.

(Side note: GitHub's PR view of the previous state was a bit deceptive — it counted lines that already existed on main because of the old merge base. After the rebase the count drops from 158/13 to the actual 102/4, which matches what's actually being added.)

@grinev
Copy link
Copy Markdown
Owner

grinev commented May 10, 2026

@avfirsov the PR history looks clean now: I see one commit on top
of the latest main, and the diff is focused on the reverse-
proxy feature.

I still have a few requests before merge:

  1. Please add explicit startup validation for proxy mode
    configuration. If TELEGRAM_PROXY_URL and TELEGRAM_API_ROOT
    are intended to be alternative modes, the bot should reject
    using both at the same time with a clear error message. Also,
    TELEGRAM_PROXY_SECRET should probably require
    TELEGRAM_API_ROOT, otherwise the secret header may be sent to
    the default Telegram API endpoint.

  2. Since the code now imports node-fetch directly, please add
    it as a direct dependency instead of relying on grammY’s
    transitive dependency.

  3. Please normalize TELEGRAM_API_ROOT by removing trailing
    slashes before passing it to grammY and before building file
    download URLs. grammY rejects apiRoot values that end with /,
    so it would be better to handle this consistently.

  4. Please add a few focused tests for the new behavior: config
    validation, custom apiRoot, X-Proxy-Secret injection, file
    download URL/header handling, and the invalid combination with
    TELEGRAM_PROXY_URL

…y setups

Adds two new environment variables for routing Telegram Bot API calls
and file downloads through a custom HTTPS reverse-proxy (e.g. nginx
proxying api.telegram.org) with an optional shared-secret header.

* TELEGRAM_API_ROOT replaces https://api.telegram.org for both Bot API
  calls (via grammY's client.apiRoot) and file downloads. Defaults to
  empty -> existing behaviour preserved. Trailing slashes are stripped
  at config load (grammY rejects apiRoot ending with '/').
* TELEGRAM_PROXY_SECRET, if set, is sent as the X-Proxy-Secret header
  on every Bot API call and every file-download fetch so the reverse
  proxy can authorize callers. Defaults to empty.

Startup validation:
* TELEGRAM_PROXY_URL and TELEGRAM_API_ROOT cannot be combined; they are
  alternative connectivity modes (forward vs reverse proxy).
* TELEGRAM_PROXY_SECRET requires TELEGRAM_API_ROOT to avoid sending the
  secret header to api.telegram.org.

Touched: src/config.ts (apiRoot/proxySecret + buildTelegramConfig with
validation), src/bot/index.ts (apiRoot wired into grammY client +
X-Proxy-Secret via fetch wrapper), src/bot/utils/file-download.ts and
src/bot/handlers/voice.ts (file URL base + secret header).

Tests: tests/config.test.ts covers validation + trailing-slash
normalization. tests/bot/utils/file-download.test.ts covers default
URL base, custom apiRoot URL base, slash-tolerant URL build, and
X-Proxy-Secret injection on/off.

node-fetch is now a direct dependency rather than a transitive one
through grammY, since src/bot/index.ts imports it explicitly.

Docs: .env.example block lives next to TELEGRAM_PROXY_URL; README
gains the two env vars in the Environment Variables table and a new
'Reverse Proxy (Optional)' subsection with a copy-pasteable nginx
config.
@avfirsov avfirsov force-pushed the reverse-proxy-apiroot-and-secret branch from f07b968 to 7a32b0b Compare May 10, 2026 15:19
@avfirsov
Copy link
Copy Markdown
Contributor Author

@grinev all four points addressed and rebased onto current main (66e5d4f, post-v0.20.2). Single commit 7a32b0b.

1. Startup validationsrc/config.ts now has a buildTelegramConfig() builder that enforces both rules and throws a descriptive Error at module load:

  • TELEGRAM_PROXY_URL + TELEGRAM_API_ROOT together → rejected (alternative connectivity modes).
  • TELEGRAM_PROXY_SECRET without TELEGRAM_API_ROOT → rejected (avoids leaking the secret header to api.telegram.org).

2. node-fetch as a direct dependency — added "node-fetch": "^2.7.0" to dependencies in package.json. Pinned to v2 because grammY 1.x ships its own v2 internally and v3 is ESM-only with a different default-export shape. Lockfile updated. The // @ts-expect-error comment stays because we still don't pull @types/node-fetch in.

3. Trailing-slash normalization — done once at config load: getEnvVar("TELEGRAM_API_ROOT", false).replace(/\/+$/, ""). Removed the per-call .replace(/\/$/, "") from file-download.ts and voice.ts. Now grammY gets a clean apiRoot, file URLs never get //file/bot..., and the concern lives in one place.

4. Tests — added focused coverage:

  • tests/config.test.ts (new describe("config telegram reverse-proxy")) — 9 cases: empty defaults, single trailing slash stripped, multiple trailing slashes stripped, no slash preserved, apiRoot + proxySecret combo accepted, proxyUrl alone allowed, proxyUrl + apiRoot rejected, proxySecret without apiRoot rejected.
  • tests/bot/utils/file-download.test.ts (new describe("downloadTelegramFile reverse-proxy wiring")) — 5 cases via vi.stubGlobal("fetch", …): default api.telegram.org URL base, custom TELEGRAM_API_ROOT URL base, slash-tolerant URL build, no X-Proxy-Secret header when secret unset, X-Proxy-Secret injected when secret set.

Aside: I exported buildTelegramConfig so the validation tests can drive it directly. Re-importing the whole config module to observe a top-level throw turned out to be flaky under vitest — the module-evaluation error didn't propagate as a clean promise rejection — so testing the builder is both more reliable and more focused.

Diff summary against main:

.env.example                            +10  -0
README.md                               +40  -0
package-lock.json                        +1  -0
package.json                             +1  -0
src/bot/handlers/voice.ts                +7  -2
src/bot/index.ts                        +27  -0
src/bot/utils/file-download.ts          +16  -2
src/config.ts                           +36  -4
tests/bot/utils/file-download.test.ts   +97  -1
tests/config.test.ts                    +84  -0
10 files, +319 -9

Local npm run lint / npm run build / npm test all green (877/877). CI rerunning on the new head.

@grinev
Copy link
Copy Markdown
Owner

grinev commented May 10, 2026

@avfirsov thanks for contribution!

@grinev grinev merged commit b220abb into grinev:main May 10, 2026
1 check passed
@avfirsov
Copy link
Copy Markdown
Contributor Author

@grinev спасибо за быстрый мерж и за внимательное ревью — все четыре пункта были по делу, фича стала чище. ❤️

@grinev
Copy link
Copy Markdown
Owner

grinev commented May 10, 2026

@avfirsov спасибо что довел до конца ПР, фича реально полезная в нынешних реалиях.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants