manual improvement 2 & add telegram bot
This commit is contained in:
@@ -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',
|
||||
};
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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', {});
|
||||
}
|
||||
Reference in New Issue
Block a user