diff --git a/bin/lib/telegram-api.js b/bin/lib/telegram-api.js new file mode 100644 index 000000000..f2f621a33 --- /dev/null +++ b/bin/lib/telegram-api.js @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Telegram Bot API client with socket timeout protection. + * + * Exported so both the bridge script and tests use the same implementation. + */ + +const https = require("https"); + +const DEFAULT_TIMEOUT_MS = 60000; + +/** + * Call a Telegram Bot API method. + * + * @param {string} token — Bot token from @BotFather + * @param {string} method — API method name (e.g. "getUpdates") + * @param {object} body — JSON-serialisable request body + * @param {object} [opts] + * @param {number} [opts.timeout] — socket idle timeout in ms (default 60 000) + * @param {string} [opts.hostname] — override hostname (useful for tests) + * @param {number} [opts.port] — override port (useful for tests) + * @param {boolean} [opts.rejectUnauthorized] — TLS cert check (default true) + * @returns {Promise} parsed JSON response + */ +function tgApi(token, method, body, opts = {}) { + const { + timeout = DEFAULT_TIMEOUT_MS, + hostname = "api.telegram.org", + port, + rejectUnauthorized, + } = opts; + + return new Promise((resolve, reject) => { + let settled = false; + const settle = (fn, value) => { + if (settled) return; + settled = true; + fn(value); + }; + + const data = JSON.stringify(body); + const reqOpts = { + hostname, + path: `/bot${token}/${method}`, + method: "POST", + timeout, + headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, + }; + if (port != null) reqOpts.port = port; + if (rejectUnauthorized != null) reqOpts.rejectUnauthorized = rejectUnauthorized; + + const req = https.request(reqOpts, (res) => { + let buf = ""; + res.setEncoding("utf8"); + res.on("data", (c) => (buf += c)); + res.on("aborted", () => settle(reject, new Error(`Telegram API ${method} response aborted`))); + res.on("error", (err) => settle(reject, err)); + res.on("end", () => { + try { + settle(resolve, JSON.parse(buf)); + } catch { + settle(resolve, { ok: false, error: buf }); + } + }); + }); + req.on("timeout", () => { + req.destroy(new Error(`Telegram API ${method} timed out`)); + }); + req.on("error", (err) => settle(reject, err)); + req.write(data); + req.end(); + }); +} + +module.exports = { tgApi, DEFAULT_TIMEOUT_MS }; diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js index 96a29fd88..91568570f 100755 --- a/scripts/telegram-bridge.js +++ b/scripts/telegram-bridge.js @@ -16,8 +16,8 @@ * ALLOWED_CHAT_IDS — comma-separated Telegram chat IDs to accept (optional, accepts all if unset) */ -const https = require("https"); const { execFileSync, spawn } = require("child_process"); +const { tgApi: tgApiRaw } = require("../bin/lib/telegram-api"); const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); const { shellQuote, validateName } = require("../bin/lib/runner"); const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); @@ -47,27 +47,7 @@ const busyChats = new Set(); // ── Telegram API helpers ────────────────────────────────────────── function tgApi(method, body) { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const req = https.request( - { - hostname: "api.telegram.org", - path: `/bot${TOKEN}/${method}`, - method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, - }, - (res) => { - let buf = ""; - res.on("data", (c) => (buf += c)); - res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } - }); - }, - ); - req.on("error", reject); - req.write(data); - req.end(); - }); + return tgApiRaw(TOKEN, method, body); } async function sendMessage(chatId, text, replyTo) { diff --git a/test/telegram-api.test.js b/test/telegram-api.test.js new file mode 100644 index 000000000..8a66dfe72 --- /dev/null +++ b/test/telegram-api.test.js @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tests for bin/lib/telegram-api.js — the shared Telegram API client. + * + * Uses local TLS servers to simulate Telegram API behavior without + * hitting the real API. Verifies socket timeout, recovery, and error + * handling using the actual production tgApi function. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { createRequire } from "node:module"; +import https from "node:https"; +import net from "node:net"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const require = createRequire(import.meta.url); +const { tgApi } = require("../bin/lib/telegram-api"); + +// ── Self-signed cert for local test servers ────────────────────────── +const tmpDir = fs.mkdtempSync("/tmp/tg-api-test-"); +const keyPath = path.join(tmpDir, "key.pem"); +const certPath = path.join(tmpDir, "cert.pem"); +execFileSync( + "openssl", + [ + "req", + "-x509", + "-newkey", + "rsa:2048", + "-keyout", + keyPath, + "-out", + certPath, + "-days", + "1", + "-nodes", + "-subj", + "/CN=localhost", + ], + { stdio: "ignore" }, +); +const key = fs.readFileSync(keyPath); +const cert = fs.readFileSync(certPath); +fs.rmSync(tmpDir, { recursive: true }); + +// ── Helpers ────────────────────────────────────────────────────────── +const servers = []; + +function createServer(handler) { + return new Promise((resolve) => { + const server = https.createServer({ key, cert }, handler); + server.listen(0, "127.0.0.1", () => { + servers.push(server); + const { port } = server.address(); + resolve({ server, port }); + }); + }); +} + +/** Build opts that point tgApi at a local test server. */ +function localOpts(port, timeoutMs = 2000) { + return { hostname: "127.0.0.1", port, timeout: timeoutMs, rejectUnauthorized: false }; +} + +afterEach(async () => { + const toClose = servers.splice(0, servers.length); + await Promise.all( + toClose.map( + (s) => + new Promise((resolve) => { + if (s.closeAllConnections) s.closeAllConnections(); + s.close(() => resolve()); + }), + ), + ); +}); + +// ── Tests ──────────────────────────────────────────────────────────── + +describe("tgApi (bin/lib/telegram-api)", () => { + it("resolves normally when server responds promptly", async () => { + const { port } = await createServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, result: { update_id: 1 } })); + }); + + const result = await tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port)); + expect(result.ok).toBe(true); + }); + + it("rejects with timeout when server hangs (simulates network drop)", async () => { + const { port } = await createServer(() => { + // never respond — simulates dead TCP connection + }); + + const start = Date.now(); + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 1000)), + ).rejects.toThrow("timed out"); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(5000); + }); + + it("timeout fires within expected window", async () => { + const { port } = await createServer(() => { + /* never respond */ + }); + + const start = Date.now(); + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)), + ).rejects.toThrow("timed out"); + const elapsed = Date.now() - start; + expect(elapsed).toBeGreaterThanOrEqual(450); + expect(elapsed).toBeLessThan(2000); + }); + + it("poll loop recovers after timeout", async () => { + let reqCount = 0; + const { port } = await createServer((_req, res) => { + reqCount++; + if (reqCount === 1) return; // first: hang + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, result: [] })); + }); + + // First call: timeout + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)), + ).rejects.toThrow("timed out"); + + // Second call: should succeed (poll loop recovery) + const result = await tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 500)); + expect(result.ok).toBe(true); + }); + + it("rejects when server closes connection mid-response", async () => { + const { port } = await createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.write('{"ok":'); + setTimeout(() => req.socket.destroy(), 50); + }); + + // With res.on("aborted") and res.on("error") handlers, a + // mid-response socket destroy now rejects instead of hanging. + const result = await Promise.race([ + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 2000)) + .then(() => "resolved") + .catch(() => "rejected"), + new Promise((r) => setTimeout(() => r("hung"), 3000)), + ]); + expect(result).not.toBe("hung"); + }); + + it("handles connection refused (server down)", async () => { + const tempServer = net.createServer(); + await new Promise((r) => tempServer.listen(0, "127.0.0.1", r)); + const { port } = tempServer.address(); + await new Promise((resolve, reject) => + tempServer.close((err) => (err ? reject(err) : resolve())), + ); + + await expect( + tgApi("fake-token", "getUpdates", { offset: 0 }, localOpts(port, 2000)), + ).rejects.toThrow(); + }); +});