110 lines
3.8 KiB
JavaScript
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', {});
|
|
}
|