balinyaar-telegram-otp-bot
A standalone 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" — you get the code on your phone instead, without an Iranian SMS gateway contract.
Broadcast, not routing. Every configured recipient receives every OTP, regardless of which phone number requested it. That makes this a shared inbox for a small trusted group, not an SMS gateway. It is the OTP rail for local development and for the pre-launch
balinyaar.irdemo deployment — it must be swapped forSeams:Sms:Provider = kavenegarbefore anyone outside that trusted group can request a code.
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. Loopback locally; the Dockerfile sets 0.0.0.0 so the API container can reach it. |
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. In the
deployed stack there is no .env at all — the root docker-compose.yml supplies every variable directly
(and .dockerignore keeps a local .env out of the image, so it can't silently win).
Running in Docker
The Dockerfile here is built by the root docker-compose.yml as
the otp-relay service. Nothing is published to the host: the API reaches it as
http://balinyaar-otp-relay:5010 over the shared caddy_net network, and its own hop to Telegram goes
through the proxy container on that same network. See DEPLOY.md.
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.