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_HOST = 'api.telegram.org'; async function callApi(method, payload, { timeoutMs = 10_000 } = {}) { const { status, text } = await postJson(`/bot${config.botToken}/${method}`, payload, timeoutMs); let body = {}; try { 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', '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 })); }); 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. */ 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', {}); }