integrate telegram bot
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
# Copy to .env and fill in. Never commit .env.
|
||||
|
||||
# From @BotFather — the full token, e.g. 1234567890:AAH....
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
TELEGRAM_BOT_TOKEN=8968527151:AAFiCuNGkXjOiLZfT6urU8tkW8SCsWDM0ic
|
||||
|
||||
# Comma-separated Telegram chat ids that receive every OTP.
|
||||
# Each of these users MUST have sent the bot at least one message first
|
||||
# (Telegram forbids a bot from opening a conversation).
|
||||
# Discover them with: GET http://localhost:5010/chat_ids (with the X-Api-Key header)
|
||||
TELEGRAM_CHAT_IDS=
|
||||
TELEGRAM_CHAT_IDS=1277103616,110209855
|
||||
|
||||
# REQUIRED. Shared secret the caller must send as the `X-Api-Key` header.
|
||||
# Minimum 16 chars; the process refuses to start without it.
|
||||
# Generate one: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"
|
||||
# The same value goes into the .NET side's Seams:Sms:Telegram:ApiKey (user-secrets, never committed).
|
||||
API_KEY=
|
||||
API_KEY=ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86
|
||||
|
||||
# HTTP listener
|
||||
PORT=5010
|
||||
@@ -23,7 +23,10 @@ HOST=127.0.0.1
|
||||
# The code is still delivered over Telegram either way.
|
||||
REDACT_CODE_IN_LOGS=false
|
||||
|
||||
# api.telegram.org is filtered in Iran. On Node 24+, these two make the built-in fetch
|
||||
# use your local proxy; point HTTPS_PROXY at whatever your VPN/proxy client listens on.
|
||||
# NODE_USE_ENV_PROXY=1
|
||||
# HTTPS_PROXY=http://127.0.0.1:10809
|
||||
# api.telegram.org is filtered in Iran. Point this at whatever your VPN / proxy client listens on and
|
||||
# this process tunnels its Telegram calls through it (HTTP CONNECT or SOCKS5, with optional
|
||||
# user:pass@ credentials). Leave it unset for a direct connection — nothing else changes.
|
||||
# local machine with a VPN client: http://127.0.0.1:10809 / socks5://127.0.0.1:10808
|
||||
# VPS with a proxy container: http://proxy:1080 (the container name on the shared docker network)
|
||||
# HTTPS_PROXY / ALL_PROXY are honoured as a fallback if TELEGRAM_PROXY_URL is unset.
|
||||
# TELEGRAM_PROXY_URL=http://127.0.0.1:10809
|
||||
|
||||
@@ -95,6 +95,7 @@ The two POST routes mirror the server's `ISmsSender` (`SendOtpAsync` / `SendAsyn
|
||||
| `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
|
||||
@@ -111,15 +112,25 @@ Values come from `.env` (git-ignored) or from real environment variables, which
|
||||
| `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
|
||||
### Reaching Telegram from Iran — the proxy option
|
||||
|
||||
`api.telegram.org` is filtered, so the machine running this needs a proxy. On **Node 24+** the built-in
|
||||
`fetch` honours the standard proxy variables once opted in — uncomment these in `.env`:
|
||||
`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`:
|
||||
|
||||
```
|
||||
NODE_USE_ENV_PROXY=1
|
||||
HTTPS_PROXY=http://127.0.0.1:10809
|
||||
TELEGRAM_PROXY_URL=http://127.0.0.1:10809 # or socks5://127.0.0.1:10808
|
||||
```
|
||||
|
||||
pointing `HTTPS_PROXY` at whatever your VPN/proxy client listens on. On older Node, run the process
|
||||
under a system-wide/TUN-mode proxy instead — `fetch` there ignores the env vars.
|
||||
- **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:10808` or `proxy: (none — direct to api.telegram.org)`).
|
||||
- **Schemes:** `http`/`https` (an HTTP `CONNECT` tunnel) and `socks5`/`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 when `TELEGRAM_PROXY_URL` is unset, so a
|
||||
container that already sets them needs no extra config. `NODE_USE_ENV_PROXY` is 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parseProxyUrl } from './proxy.js';
|
||||
|
||||
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
@@ -60,10 +61,32 @@ if (apiKey.length < 16) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Opt-in outbound proxy for this process's own hop to api.telegram.org (filtered in Iran). Absent ⇒ direct,
|
||||
// exactly as before. TELEGRAM_PROXY_URL wins so the relay can be proxied without proxying anything else on the
|
||||
// box; the standard variables are honoured as a fallback because that is where a container already puts it.
|
||||
const proxyUrl = (
|
||||
process.env.TELEGRAM_PROXY_URL ??
|
||||
process.env.HTTPS_PROXY ?? process.env.https_proxy ??
|
||||
process.env.ALL_PROXY ?? process.env.all_proxy ??
|
||||
''
|
||||
).trim();
|
||||
|
||||
let proxy = null;
|
||||
if (proxyUrl) {
|
||||
try {
|
||||
proxy = parseProxyUrl(proxyUrl);
|
||||
} catch (error) {
|
||||
// Fail at boot: a misspelled proxy would otherwise surface as an unexplained delivery failure per OTP.
|
||||
console.error(`FATAL: ${error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export const config = {
|
||||
botToken,
|
||||
chatIds,
|
||||
apiKey,
|
||||
proxy,
|
||||
host: (process.env.HOST ?? '127.0.0.1').trim(),
|
||||
port: Number(process.env.PORT ?? 5010),
|
||||
redactCodeInLogs: (process.env.REDACT_CODE_IN_LOGS ?? 'false').toLowerCase() === 'true',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import net from 'node:net';
|
||||
import tls from 'node:tls';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
/**
|
||||
* Opt-in outbound proxy for the one hop that is filtered in Iran: this process → api.telegram.org.
|
||||
*
|
||||
* Node's built-in `fetch` only honours `HTTPS_PROXY` on Node 24+ and only behind `NODE_USE_ENV_PROXY`, which
|
||||
* makes "does the proxy apply?" depend on the runtime. Tunnelling the socket here instead keeps the behaviour
|
||||
* identical on every supported Node and needs no dependency: an HTTP `CONNECT` tunnel or a SOCKS5 handshake,
|
||||
* both of which any local VPN/proxy client (or a proxy container on the VPS) exposes.
|
||||
*
|
||||
* With no proxy configured nothing in this file runs — the request goes out directly, as before.
|
||||
*/
|
||||
|
||||
const SOCKS_VERSION = 0x05;
|
||||
const SOCKS_NO_AUTH = 0x00;
|
||||
const SOCKS_USER_PASS = 0x02;
|
||||
const SOCKS_CMD_CONNECT = 0x01;
|
||||
const SOCKS_ATYP_DOMAIN = 0x03;
|
||||
|
||||
/** Parses a proxy URL into the shape the connectors need, or throws with a usable message. */
|
||||
export function parseProxyUrl(raw) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`invalid proxy URL "${raw}" — expected e.g. http://127.0.0.1:10809 or socks5://127.0.0.1:10808`);
|
||||
}
|
||||
|
||||
const scheme = url.protocol.replace(':', '').toLowerCase();
|
||||
if (!['http', 'https', 'socks', 'socks5', 'socks5h'].includes(scheme)) {
|
||||
throw new Error(`unsupported proxy scheme "${scheme}" — use http, https, or socks5`);
|
||||
}
|
||||
|
||||
const port = url.port ? Number(url.port) : scheme === 'https' ? 443 : scheme === 'http' ? 8080 : 1080;
|
||||
|
||||
return {
|
||||
scheme,
|
||||
host: url.hostname,
|
||||
port,
|
||||
username: url.username ? decodeURIComponent(url.username) : '',
|
||||
password: url.password ? decodeURIComponent(url.password) : '',
|
||||
// What to print in logs — never the credentials.
|
||||
label: `${scheme}://${url.hostname}:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns a socket already tunnelled to `host:port` through the proxy. The caller TLS-wraps it. */
|
||||
export function connectThroughProxy(proxy, host, port, timeoutMs) {
|
||||
const connect = proxy.scheme === 'http' || proxy.scheme === 'https' ? httpConnect : socks5Connect;
|
||||
return withTimeout(connect(proxy, host, port), timeoutMs, `proxy ${proxy.label} did not connect`);
|
||||
}
|
||||
|
||||
function openToProxy(proxy) {
|
||||
return proxy.scheme === 'https'
|
||||
? tls.connect({ host: proxy.host, port: proxy.port, servername: proxy.host })
|
||||
: net.connect({ host: proxy.host, port: proxy.port });
|
||||
}
|
||||
|
||||
/** RFC 7231 `CONNECT` tunnel — what an HTTP proxy exposes for TLS traffic. */
|
||||
function httpConnect(proxy, host, port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = openToProxy(proxy);
|
||||
const fail = (error) => {
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
socket.once('error', fail);
|
||||
socket.once(proxy.scheme === 'https' ? 'secureConnect' : 'connect', () => {
|
||||
const lines = [`CONNECT ${host}:${port} HTTP/1.1`, `Host: ${host}:${port}`];
|
||||
if (proxy.username) {
|
||||
const credentials = Buffer.from(`${proxy.username}:${proxy.password}`).toString('base64');
|
||||
lines.push(`Proxy-Authorization: Basic ${credentials}`);
|
||||
}
|
||||
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
||||
});
|
||||
|
||||
let buffer = Buffer.alloc(0);
|
||||
const onData = (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
const headerEnd = buffer.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) return;
|
||||
|
||||
socket.removeListener('data', onData);
|
||||
const statusLine = buffer.subarray(0, buffer.indexOf('\r\n')).toString('latin1');
|
||||
if (!/^HTTP\/1\.[01] 200/.test(statusLine)) {
|
||||
fail(new Error(`proxy refused CONNECT: ${statusLine}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// A compliant proxy sends nothing after the blank line, but push anything it did back for the TLS layer.
|
||||
const leftover = buffer.subarray(headerEnd + 4);
|
||||
if (leftover.length) socket.unshift(leftover);
|
||||
|
||||
socket.removeListener('error', fail);
|
||||
resolve(socket);
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
}
|
||||
|
||||
/** RFC 1928 SOCKS5 (+ RFC 1929 username/password) — what most local proxy clients expose. */
|
||||
async function socks5Connect(proxy, host, port) {
|
||||
const socket = openToProxy(proxy);
|
||||
|
||||
try {
|
||||
await once(socket, 'connect');
|
||||
const { read, release } = reader(socket);
|
||||
|
||||
const methods = proxy.username ? [SOCKS_NO_AUTH, SOCKS_USER_PASS] : [SOCKS_NO_AUTH];
|
||||
socket.write(Buffer.from([SOCKS_VERSION, methods.length, ...methods]));
|
||||
|
||||
const greeting = await read(2);
|
||||
if (greeting[0] !== SOCKS_VERSION) throw new Error('proxy is not SOCKS5');
|
||||
|
||||
if (greeting[1] === SOCKS_USER_PASS) {
|
||||
const user = Buffer.from(proxy.username);
|
||||
const pass = Buffer.from(proxy.password);
|
||||
socket.write(Buffer.concat([
|
||||
Buffer.from([0x01, user.length]), user, Buffer.from([pass.length]), pass,
|
||||
]));
|
||||
const auth = await read(2);
|
||||
if (auth[1] !== 0x00) throw new Error('SOCKS5 proxy rejected the credentials');
|
||||
} else if (greeting[1] !== SOCKS_NO_AUTH) {
|
||||
throw new Error('SOCKS5 proxy demands an unsupported authentication method');
|
||||
}
|
||||
|
||||
// ATYP = domain, so the *proxy* resolves api.telegram.org — local DNS is filtered too.
|
||||
const target = Buffer.from(host);
|
||||
const request = Buffer.concat([
|
||||
Buffer.from([SOCKS_VERSION, SOCKS_CMD_CONNECT, 0x00, SOCKS_ATYP_DOMAIN, target.length]),
|
||||
target,
|
||||
Buffer.from([(port >> 8) & 0xff, port & 0xff]),
|
||||
]);
|
||||
socket.write(request);
|
||||
|
||||
const reply = await read(4);
|
||||
if (reply[1] !== 0x00) throw new Error(`SOCKS5 proxy refused CONNECT (code ${reply[1]})`);
|
||||
|
||||
// Drain the bound address so the stream starts at the tunnelled payload.
|
||||
const boundLength = reply[3] === 0x01 ? 4 : reply[3] === 0x04 ? 16 : (await read(1))[0];
|
||||
await read(boundLength + 2);
|
||||
|
||||
return release();
|
||||
} catch (error) {
|
||||
socket.destroy();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads exactly N bytes at a time during the handshake. `release()` hands the socket back with any
|
||||
* already-buffered bytes pushed in front, so the TLS layer sees an untouched stream.
|
||||
*/
|
||||
function reader(socket) {
|
||||
let buffer = Buffer.alloc(0);
|
||||
let pending = null;
|
||||
|
||||
const pump = () => {
|
||||
if (!pending || buffer.length < pending.size) return;
|
||||
const { size, resolve } = pending;
|
||||
pending = null;
|
||||
const chunk = buffer.subarray(0, size);
|
||||
buffer = buffer.subarray(size);
|
||||
resolve(chunk);
|
||||
};
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
pump();
|
||||
});
|
||||
|
||||
const read = (size) => new Promise((resolve, reject) => {
|
||||
pending = { size, resolve };
|
||||
socket.once('error', reject);
|
||||
pump();
|
||||
});
|
||||
|
||||
const release = () => {
|
||||
socket.removeAllListeners('data');
|
||||
socket.removeAllListeners('error');
|
||||
if (buffer.length) socket.unshift(buffer);
|
||||
return socket;
|
||||
};
|
||||
|
||||
return { read, release };
|
||||
}
|
||||
|
||||
function once(emitter, event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
emitter.once(event, resolve);
|
||||
emitter.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function withTimeout(promise, timeoutMs, message) {
|
||||
let timer;
|
||||
const timeout = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`${message} within ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
@@ -96,6 +96,7 @@ const routes = {
|
||||
if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' });
|
||||
|
||||
const shown = config.redactCodeInLogs ? '******' : code;
|
||||
console.log(`sending OTP ${shown} for ${phone} → ${config.chatIds.length} recipients`);
|
||||
return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`);
|
||||
},
|
||||
|
||||
@@ -133,6 +134,7 @@ server.listen(config.port, config.host, async () => {
|
||||
console.log(`balinyaar telegram-otp-bot listening on http://${config.host}:${config.port}`);
|
||||
console.log(` recipients: ${config.chatIds.length ? config.chatIds.join(', ') : '(none — call GET /chat_ids to discover)'}`);
|
||||
console.log(' auth: X-Api-Key required on every route except GET /health');
|
||||
console.log(` proxy: ${config.proxy ? config.proxy.label : '(none — direct to api.telegram.org)'}`);
|
||||
|
||||
try {
|
||||
const me = await getBotIdentity();
|
||||
|
||||
@@ -1,31 +1,64 @@
|
||||
import https from 'node:https';
|
||||
import tls from 'node:tls';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { config } from './env.js';
|
||||
import { connectThroughProxy } from './proxy.js';
|
||||
|
||||
const API_BASE = `https://api.telegram.org/bot${config.botToken}`;
|
||||
const API_HOST = 'api.telegram.org';
|
||||
|
||||
async function callApi(method, payload, { timeoutMs = 10_000 } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const { status, text } = await postJson(`/bot${config.botToken}/${method}`, payload, timeoutMs);
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/${method}`, {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
// keep the empty body; the status check below reports it
|
||||
}
|
||||
|
||||
if (status < 200 || status >= 300 || body.ok !== true) {
|
||||
// Telegram carries the real failure in `description` (e.g. "chat not found" when the
|
||||
// recipient never messaged the bot first) — surface it verbatim so the cause is obvious.
|
||||
throw new Error(body.description ?? `HTTP ${status}`);
|
||||
}
|
||||
|
||||
return body.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* One HTTPS POST to Telegram. Built on `node:https` rather than `fetch` so the optional proxy applies
|
||||
* identically on every supported Node version: with a proxy configured the request rides a tunnelled socket
|
||||
* (see `proxy.js`), without one it takes the default direct route.
|
||||
*/
|
||||
async function postJson(path, payload, timeoutMs) {
|
||||
const body = JSON.stringify(payload);
|
||||
const tunnel = config.proxy
|
||||
? await connectThroughProxy(config.proxy, API_HOST, 443, timeoutMs)
|
||||
: null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.request({
|
||||
host: API_HOST,
|
||||
port: 443,
|
||||
path,
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
||||
// Only set when tunnelling: passing createConnection is what makes node skip the default agent.
|
||||
...(tunnel ? { createConnection: () => tls.connect({ socket: tunnel, servername: API_HOST }) } : {}),
|
||||
}, (response) => {
|
||||
let text = '';
|
||||
response.setEncoding('utf8');
|
||||
response.on('data', (chunk) => { text += chunk; });
|
||||
response.on('end', () => resolve({ status: response.statusCode, text }));
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok || body.ok !== true) {
|
||||
// Telegram carries the real failure in `description` (e.g. "chat not found" when the
|
||||
// recipient never messaged the bot first) — surface it verbatim so the cause is obvious.
|
||||
const reason = body.description ?? `HTTP ${response.status}`;
|
||||
throw new Error(reason);
|
||||
}
|
||||
|
||||
return body.result;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
request.setTimeout(timeoutMs, () => request.destroy(new Error(`no response within ${timeoutMs}ms`)));
|
||||
request.on('error', (error) => {
|
||||
if (tunnel) tunnel.destroy();
|
||||
reject(error);
|
||||
});
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */
|
||||
|
||||
Reference in New Issue
Block a user