Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions bin/lib/telegram-api.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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 };
24 changes: 2 additions & 22 deletions scripts/telegram-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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) {
Expand Down
172 changes: 172 additions & 0 deletions test/telegram-api.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});