manual improvement 2 & add telegram bot

This commit is contained in:
hamid
2026-07-27 23:58:16 +03:30
parent baa3cc63cd
commit e6a8f93a1e
57 changed files with 4181 additions and 2484 deletions
+70
View File
@@ -0,0 +1,70 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Loads `.env` into process.env without a dependency. Node's own `--env-file` needs 20.6+;
* parsing it here keeps the runbook a plain `npm start` on any Node 18+.
* Real environment variables always win, so a container/CI can override the file.
*/
function loadDotEnv() {
let raw;
try {
raw = readFileSync(resolve(projectRoot, '.env'), 'utf8');
} catch {
return;
}
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq === -1) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))) {
value = value.slice(1, -1);
}
if (!(key in process.env)) process.env[key] = value;
}
}
loadDotEnv();
const botToken = (process.env.TELEGRAM_BOT_TOKEN ?? '').trim();
if (!botToken) {
console.error('FATAL: TELEGRAM_BOT_TOKEN is not set. Copy .env.example to .env and paste the token from @BotFather.');
process.exit(1);
}
const chatIds = (process.env.TELEGRAM_CHAT_IDS ?? '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
// The API key is mandatory, with no opt-out: an OTP relay that anyone can POST to is an open
// message cannon pointed at the test group's phones. Fail to start rather than listen unprotected.
const apiKey = (process.env.API_KEY ?? '').trim();
if (!apiKey) {
console.error('FATAL: API_KEY is not set. Choose any shared secret, put it in .env, and configure the same value on the caller.');
process.exit(1);
}
if (apiKey.length < 16) {
console.error(`FATAL: API_KEY is too short (${apiKey.length} chars). Use at least 16 — e.g. output of: node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"`);
process.exit(1);
}
export const config = {
botToken,
chatIds,
apiKey,
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',
};
+147
View File
@@ -0,0 +1,147 @@
import { createServer } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { config } from './env.js';
import { broadcast, discoverChatIds, getBotIdentity } from './telegram.js';
const MAX_BODY_BYTES = 16 * 1024;
function json(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
res.end(body);
}
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
reject(new Error('request body too large'));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('error', reject);
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8').trim();
if (!raw) return resolve({});
try {
resolve(JSON.parse(raw));
} catch {
reject(new Error('request body is not valid JSON'));
}
});
});
}
/** Constant-time `X-Api-Key` comparison. The key is always required — `config` guarantees one exists. */
function isAuthorized(req) {
const supplied = req.headers['x-api-key'];
if (typeof supplied !== 'string') return false;
const a = Buffer.from(supplied);
const b = Buffer.from(config.apiKey);
return a.length === b.length && timingSafeEqual(a, b);
}
function otpMessage(phone, code) {
return ['🔐 Balinyaar — login code', '', `phone: ${phone}`, `code: ${code}`, '', new Date().toISOString()].join('\n');
}
function plainMessage(phone, message) {
return ['📩 Balinyaar', '', `phone: ${phone}`, '', message].join('\n');
}
async function deliver(res, text, logLine) {
if (config.chatIds.length === 0) {
console.error('refused: TELEGRAM_CHAT_IDS is empty — nowhere to deliver');
return json(res, 503, { ok: false, error: 'no recipients configured (TELEGRAM_CHAT_IDS is empty)' });
}
const { delivered, failed } = await broadcast(text);
console.log(`${logLine} → delivered ${delivered.length}/${config.chatIds.length}`);
for (const f of failed) console.error(`${f.chatId}: ${f.error}`);
// Partial delivery still counts as sent — one reachable recipient is enough to read the code.
// Only a total failure is reported upstream, so the API's OTP command fails loudly instead of
// pretending an undeliverable code was sent.
const status = delivered.length > 0 ? 200 : 502;
return json(res, status, { ok: delivered.length > 0, delivered, failed });
}
const routes = {
// The only unauthenticated route, and it deliberately echoes no configuration — it exists so a
// caller can answer "is the relay up?" without holding the key.
'GET /health': async (_req, res) => json(res, 200, { ok: true }),
'GET /chat_ids': async (_req, res) => {
const chats = await discoverChatIds();
return json(res, 200, {
ok: true,
chats,
hint: chats.length
? 'Copy the chat_id values into TELEGRAM_CHAT_IDS (comma-separated) and restart.'
: 'No recent messages. Open the bot in Telegram, press Start / send it any message, then call this again.',
});
},
'POST /send_otp': async (req, res) => {
const { phone, code } = await readJsonBody(req);
if (!phone || !code) return json(res, 400, { ok: false, error: '`phone` and `code` are required' });
const shown = config.redactCodeInLogs ? '******' : code;
return deliver(res, otpMessage(phone, code), `otp ${shown} for ${phone}`);
},
'POST /send': async (req, res) => {
const { phone, message } = await readJsonBody(req);
if (!phone || !message) return json(res, 400, { ok: false, error: '`phone` and `message` are required' });
return deliver(res, plainMessage(phone, message), `message for ${phone}`);
},
};
const server = createServer(async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname.replace(/\/+$/, '') || '/';
const key = `${req.method} ${path}`;
const handler = routes[key];
if (!handler) {
return json(res, 404, { ok: false, error: `no route for ${key}`, routes: Object.keys(routes) });
}
if (key !== 'GET /health' && !isAuthorized(req)) {
console.error(`401 ${key} from ${req.socket.remoteAddress} — missing or invalid X-Api-Key`);
return json(res, 401, { ok: false, error: 'missing or invalid X-Api-Key' });
}
try {
await handler(req, res);
} catch (error) {
console.error(`${key} failed:`, error.message);
if (!res.headersSent) json(res, 500, { ok: false, error: error.message });
}
});
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');
try {
const me = await getBotIdentity();
console.log(` bot: @${me.username} (${me.id})`);
} catch (error) {
console.error(` bot: token check FAILED — ${error.message}`);
}
});
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => server.close(() => process.exit(0)));
}
+76
View File
@@ -0,0 +1,76 @@
import { config } from './env.js';
const API_BASE = `https://api.telegram.org/bot${config.botToken}`;
async function callApi(method, payload, { timeoutMs = 10_000 } = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE}/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal: controller.signal,
});
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);
}
}
/** Sends one message to every configured chat id. Never rejects — per-recipient outcomes are returned. */
export async function broadcast(text) {
const results = await Promise.all(
config.chatIds.map(async (chatId) => {
try {
await callApi('sendMessage', { chat_id: chatId, text, disable_notification: false });
return { chatId, ok: true };
} catch (error) {
return { chatId, ok: false, error: error.message };
}
}),
);
return {
delivered: results.filter((r) => r.ok).map((r) => r.chatId),
failed: results.filter((r) => !r.ok).map(({ chatId, error }) => ({ chatId, error })),
};
}
/**
* Lists the chat ids that have messaged the bot recently, so a new recipient can be discovered
* without hunting through the Telegram API by hand. getUpdates only sees messages newer than the
* last 24h and returns nothing while a webhook is set.
*/
export async function discoverChatIds() {
const updates = await callApi('getUpdates', { limit: 100, timeout: 0 });
const seen = new Map();
for (const update of updates) {
const chat = update.message?.chat ?? update.edited_message?.chat ?? update.channel_post?.chat;
if (!chat) continue;
seen.set(String(chat.id), {
chat_id: String(chat.id),
type: chat.type,
username: chat.username ?? null,
title: chat.title ?? ([chat.first_name, chat.last_name].filter(Boolean).join(' ') || null),
});
}
return [...seen.values()];
}
export async function getBotIdentity() {
return callApi('getMe', {});
}