balinyaar-telegram-otp-bot
A standalone, dev-only Telegram relay. It is not part of client/ or server/ — it is its own
tiny Node project with zero dependencies (Node 18+ built-ins only: node:http + global fetch).
Its whole job: expose an HTTP endpoint that the .NET API calls, and forward the message to a fixed list of Telegram chat ids. That replaces "read the OTP out of the server log" during manual testing — you get the code on your phone instead, without paying an Iranian SMS gateway.
Development only. There is no per-user routing: every configured recipient receives every OTP, regardless of which phone number requested it. That is fine for a test group; it is not an SMS gateway. Do not point a real environment at this.
Setup
-
Create the bot. Message @BotFather →
/newbot→ follow the prompts → copy the token (1234567890:AAH...). -
Configure.
cd telegram-otp-bot cp .env.example .env # PowerShell: Copy-Item .env.example .env node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"Paste the token into
TELEGRAM_BOT_TOKENand the generated secret intoAPI_KEY(required — the process refuses to start without it). LeaveTELEGRAM_CHAT_IDSempty for now. -
Run it.
npm startNo
npm installneeded — there are no dependencies. On boot it prints the bot's@username, which confirms the token works. -
Discover the chat ids. Each recipient opens the bot in Telegram and presses Start (a bot cannot open a conversation — the user must message it first). Then:
curl -H "X-Api-Key: $API_KEY" http://localhost:5010/chat_idsCopy the returned
chat_idvalues intoTELEGRAM_CHAT_IDS(comma-separated) and restart. -
Test it.
curl -X POST http://localhost:5010/send_otp \ -H "X-Api-Key: $API_KEY" \ -H 'content-type: application/json' \ -d '{"phone":"09120000001","code":"123456"}'
HTTP API
Base URL: http://127.0.0.1:5010 (configurable via HOST/PORT).
Every route except GET /health requires the X-Api-Key header, compared against API_KEY in
constant time. There is no way to disable it: API_KEY is mandatory (min 16 chars) and the process
exits at boot without one, so the relay is never reachable unauthenticated. A rejected request is
logged with the caller's address.
| Route | Auth | Body | Purpose |
|---|---|---|---|
GET /health |
— | — | Liveness only; echoes no configuration. |
GET /chat_ids |
X-Api-Key |
— | Chat ids that recently messaged the bot (setup helper). |
POST /send_otp |
X-Api-Key |
{ "phone": "...", "code": "..." } |
Broadcast a login code. |
POST /send |
X-Api-Key |
{ "phone": "...", "message": "..." } |
Broadcast a free-form transactional message. |
The two POST routes mirror the server's ISmsSender (SendOtpAsync / SendAsync) one-for-one, so a
TelegramSmsSender adapter is a thin HTTP call per method.
Response
{ "ok": true, "delivered": ["11111111"], "failed": [{ "chatId": "22222222", "error": "chat not found" }] }
200— at least one recipient received it (partial delivery still counts; one reachable reader is enough to complete a login).502— no recipient received it. The caller should treat this as a delivery failure so the OTP command fails loudly rather than pretending an undeliverable code was sent.503—TELEGRAM_CHAT_IDSis empty.400/401— bad body / missing-or-wrongX-Api-Key.
Configuration
| Variable | Default | Meaning |
|---|---|---|
TELEGRAM_BOT_TOKEN |
— | Required. From @BotFather. Process exits without it. |
API_KEY |
— | Required, min 16 chars. Shared secret expected in X-Api-Key. Process exits without it. |
TELEGRAM_CHAT_IDS |
— | Comma-separated recipient chat ids. Empty ⇒ every send returns 503. |
PORT |
5010 |
HTTP port. |
HOST |
127.0.0.1 |
Bind address. Keep it loopback unless the API runs on another machine. |
REDACT_CODE_IN_LOGS |
false |
Keep the code out of this process's stdout (still delivered). |
TELEGRAM_PROXY_URL |
— | Optional outbound proxy for the Telegram hop — see below. |
The API key is the only access control — there is no IP allow-list and no TLS. Keep HOST on
loopback when the API runs on the same machine; if you must expose it, put it behind something that
terminates TLS, or the key travels in clear text.
Values come from .env (git-ignored) or from real environment variables, which take precedence.
Troubleshooting
| Symptom | Cause |
|---|---|
chat not found in failed[] |
That user never messaged the bot. Press Start in Telegram. |
GET /chat_ids returns nothing |
No message in the last 24h, or a webhook is set on the bot (getUpdates returns nothing while a webhook is registered — call deleteWebhook). |
token check FAILED / fetch failed |
Wrong/revoked token, or no outbound access to api.telegram.org — see below. |
401 on every call |
The caller isn't sending X-Api-Key, or its value differs from API_KEY. |
Reaching Telegram from Iran — the proxy option
api.telegram.org is filtered, so the machine running this usually needs a proxy for its own hop to
Telegram. (The API → relay hop is loopback/LAN and never proxied.) Set one URL in .env:
TELEGRAM_PROXY_URL=http://127.0.0.1:10809 # or socks5://127.0.0.1:10808
- Opt-in. Unset ⇒ the relay connects directly, byte-for-byte as before. The boot banner prints which
it is (
proxy: socks5://127.0.0.1:10808orproxy: (none — direct to api.telegram.org)). - Schemes:
http/https(an HTTPCONNECTtunnel) andsocks5/socks5h. Credentials go in the URL (socks5://user:pass@host:1080) and are never logged. - Local machine with a VPN client → point it at that client's HTTP or SOCKS listener.
VPS with a proxy client in a docker container → put the relay and the proxy on the same docker
network and use the container name, e.g.
http://proxy:1080; from the host,http://127.0.0.1:<published-port>. - SOCKS5 requests are sent with the hostname (not a pre-resolved IP), so DNS resolves at the proxy — local DNS is filtered too.
HTTPS_PROXY/ALL_PROXY(either case) are used as a fallback whenTELEGRAM_PROXY_URLis unset, so a container that already sets them needs no extra config.NODE_USE_ENV_PROXYis not needed and not read — the tunnel is handled in-process, so behaviour is the same on every Node ≥ 18.- A malformed proxy URL is fatal at boot, not silently at the first OTP.