Files
baya-monorepo/telegram-otp-bot/src/telegram.js
T
2026-07-28 22:25:15 +03:30

110 lines
3.8 KiB
JavaScript

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', {});
}