|
1 | 1 | // Npm Telegram Rtt Driver tests cover npm telegram rtt driver script behavior. |
2 | 2 | import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; |
3 | 3 | import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 4 | +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; |
4 | 5 | import { tmpdir } from "node:os"; |
5 | 6 | import path from "node:path"; |
6 | 7 | import { setTimeout as delay } from "node:timers/promises"; |
@@ -103,6 +104,208 @@ type DriverCaseResult = { |
103 | 104 | elapsedMs?: number; |
104 | 105 | }; |
105 | 106 |
|
| 107 | +type AsyncDriverResult = { |
| 108 | + status: number | null; |
| 109 | + signal: NodeJS.Signals | null; |
| 110 | + stdout: string; |
| 111 | + stderr: string; |
| 112 | + timedOut: boolean; |
| 113 | +}; |
| 114 | + |
| 115 | +type TelegramUpdate = { |
| 116 | + update_id: number; |
| 117 | + message: { |
| 118 | + message_id: number; |
| 119 | + date: number; |
| 120 | + chat: { id: number }; |
| 121 | + from: { id: number; username: string }; |
| 122 | + reply_to_message?: { message_id: number }; |
| 123 | + text: string; |
| 124 | + }; |
| 125 | +}; |
| 126 | + |
| 127 | +function runDriverAsync(env: Record<string, string>, timeoutMs = 5000): Promise<AsyncDriverResult> { |
| 128 | + return new Promise((resolve) => { |
| 129 | + const child = spawn(process.execPath, [DRIVER_SCRIPT], { |
| 130 | + env: { |
| 131 | + ...process.env, |
| 132 | + OPENCLAW_QA_TELEGRAM_DRIVER_BOT_TOKEN: "driver-token", |
| 133 | + OPENCLAW_QA_TELEGRAM_GROUP_ID: "-100123", |
| 134 | + OPENCLAW_QA_TELEGRAM_SUT_BOT_TOKEN: "sut-token", |
| 135 | + ...env, |
| 136 | + }, |
| 137 | + stdio: "pipe", |
| 138 | + }); |
| 139 | + let stdout = ""; |
| 140 | + let stderr = ""; |
| 141 | + let settled = false; |
| 142 | + const timeout = setTimeout(() => { |
| 143 | + if (settled) { |
| 144 | + return; |
| 145 | + } |
| 146 | + settled = true; |
| 147 | + child.kill("SIGKILL"); |
| 148 | + resolve({ status: null, signal: "SIGKILL", stdout, stderr, timedOut: true }); |
| 149 | + }, timeoutMs); |
| 150 | + timeout.unref?.(); |
| 151 | + child.stdout.on("data", (chunk) => { |
| 152 | + stdout += String(chunk); |
| 153 | + }); |
| 154 | + child.stderr.on("data", (chunk) => { |
| 155 | + stderr += String(chunk); |
| 156 | + }); |
| 157 | + child.on("close", (status, signal) => { |
| 158 | + if (settled) { |
| 159 | + return; |
| 160 | + } |
| 161 | + settled = true; |
| 162 | + clearTimeout(timeout); |
| 163 | + resolve({ status, signal, stdout, stderr, timedOut: false }); |
| 164 | + }); |
| 165 | + }); |
| 166 | +} |
| 167 | + |
| 168 | +function readRequestJson(req: IncomingMessage): Promise<Record<string, unknown>> { |
| 169 | + return new Promise((resolve, reject) => { |
| 170 | + const chunks: Buffer[] = []; |
| 171 | + req.on("data", (chunk) => { |
| 172 | + chunks.push(Buffer.from(chunk)); |
| 173 | + }); |
| 174 | + req.on("error", reject); |
| 175 | + req.on("end", () => { |
| 176 | + const raw = Buffer.concat(chunks).toString("utf8").trim(); |
| 177 | + if (!raw) { |
| 178 | + resolve({}); |
| 179 | + return; |
| 180 | + } |
| 181 | + try { |
| 182 | + resolve(JSON.parse(raw) as Record<string, unknown>); |
| 183 | + } catch (error) { |
| 184 | + reject(error); |
| 185 | + } |
| 186 | + }); |
| 187 | + }); |
| 188 | +} |
| 189 | + |
| 190 | +function writeTelegramJson(res: ServerResponse, payload: unknown): void { |
| 191 | + const body = `${JSON.stringify(payload)}\n`; |
| 192 | + res.writeHead(200, { |
| 193 | + "content-length": Buffer.byteLength(body), |
| 194 | + "content-type": "application/json", |
| 195 | + }); |
| 196 | + res.end(body); |
| 197 | +} |
| 198 | + |
| 199 | +function closeServer(server: Server): Promise<void> { |
| 200 | + return new Promise((resolve, reject) => { |
| 201 | + server.close((error) => { |
| 202 | + if (error) { |
| 203 | + reject(error); |
| 204 | + return; |
| 205 | + } |
| 206 | + resolve(); |
| 207 | + }); |
| 208 | + }); |
| 209 | +} |
| 210 | + |
| 211 | +async function startTelegramApiServer(options: { |
| 212 | + canaryReplyToRequest: boolean; |
| 213 | +}): Promise<{ baseUrl: string; close: () => Promise<void> }> { |
| 214 | + const pendingUpdates: TelegramUpdate[] = []; |
| 215 | + let nextMessageId = 100; |
| 216 | + let nextUpdateId = 1; |
| 217 | + const chatId = -100123; |
| 218 | + const nowUnixSeconds = () => Math.floor(Date.now() / 1000); |
| 219 | + const pushSutUpdate = (params: { replyToMessageId?: number; text: string }) => { |
| 220 | + pendingUpdates.push({ |
| 221 | + update_id: nextUpdateId++, |
| 222 | + message: { |
| 223 | + message_id: nextMessageId++, |
| 224 | + date: nowUnixSeconds(), |
| 225 | + chat: { id: chatId }, |
| 226 | + from: { id: 222, username: "sut_bot" }, |
| 227 | + ...(params.replyToMessageId === undefined |
| 228 | + ? {} |
| 229 | + : { reply_to_message: { message_id: params.replyToMessageId } }), |
| 230 | + text: params.text, |
| 231 | + }, |
| 232 | + }); |
| 233 | + }; |
| 234 | + |
| 235 | + const server = createServer((req, res) => { |
| 236 | + void (async () => { |
| 237 | + const match = req.url?.match(/^\/bot([^/]+)\/([^/?]+)/u); |
| 238 | + if (!match) { |
| 239 | + res.writeHead(404).end(); |
| 240 | + return; |
| 241 | + } |
| 242 | + const [, token, method] = match; |
| 243 | + const body = await readRequestJson(req); |
| 244 | + |
| 245 | + if (method === "getMe") { |
| 246 | + writeTelegramJson(res, { |
| 247 | + ok: true, |
| 248 | + result: |
| 249 | + token === "sut-token" |
| 250 | + ? { id: 222, username: "sut_bot" } |
| 251 | + : { id: 111, username: "driver_bot" }, |
| 252 | + }); |
| 253 | + return; |
| 254 | + } |
| 255 | + |
| 256 | + if (method === "sendMessage") { |
| 257 | + const messageId = nextMessageId++; |
| 258 | + const text = String(body.text ?? ""); |
| 259 | + if (token === "driver-token" && text.startsWith("/status@")) { |
| 260 | + pushSutUpdate({ |
| 261 | + replyToMessageId: options.canaryReplyToRequest ? messageId : undefined, |
| 262 | + text: "status ok", |
| 263 | + }); |
| 264 | + } else if (token === "driver-token" && text.includes("Reply with exactly ")) { |
| 265 | + const marker = text.match(/Reply with exactly ([^.]+)\./u)?.[1] ?? "OPENCLAW_E2E_OK_1"; |
| 266 | + pushSutUpdate({ replyToMessageId: messageId, text: marker }); |
| 267 | + } |
| 268 | + writeTelegramJson(res, { |
| 269 | + ok: true, |
| 270 | + result: { |
| 271 | + message_id: messageId, |
| 272 | + date: nowUnixSeconds(), |
| 273 | + chat: { id: chatId }, |
| 274 | + text, |
| 275 | + }, |
| 276 | + }); |
| 277 | + return; |
| 278 | + } |
| 279 | + |
| 280 | + if (method === "getUpdates") { |
| 281 | + const offset = typeof body.offset === "number" ? body.offset : 0; |
| 282 | + const updates = pendingUpdates.filter((update) => update.update_id >= offset); |
| 283 | + if (updates.length === 0) { |
| 284 | + await delay(25); |
| 285 | + } |
| 286 | + writeTelegramJson(res, { ok: true, result: updates }); |
| 287 | + return; |
| 288 | + } |
| 289 | + |
| 290 | + writeTelegramJson(res, { ok: false, description: `unexpected method ${method}` }); |
| 291 | + })().catch((error) => { |
| 292 | + res.writeHead(500, { "content-type": "text/plain" }).end(String(error)); |
| 293 | + }); |
| 294 | + }); |
| 295 | + |
| 296 | + await new Promise<void>((resolve) => { |
| 297 | + server.listen(0, "127.0.0.1", resolve); |
| 298 | + }); |
| 299 | + const address = server.address(); |
| 300 | + if (!address || typeof address === "string") { |
| 301 | + throw new Error("fake Telegram server did not bind a TCP port"); |
| 302 | + } |
| 303 | + return { |
| 304 | + baseUrl: `http://127.0.0.1:${address.port}`, |
| 305 | + close: () => closeServer(server), |
| 306 | + }; |
| 307 | +} |
| 308 | + |
106 | 309 | async function runStalledTelegramBodyCase(): Promise<DriverCaseResult> { |
107 | 310 | const root = mkdtempSync(path.join(tmpdir(), "openclaw-telegram-rtt-driver-")); |
108 | 311 | const portPath = path.join(root, "port.txt"); |
@@ -239,4 +442,77 @@ describe("npm Telegram RTT driver", () => { |
239 | 442 | expect(result.stderr).toContain("Telegram Bot API getMe response body exceeded 16 bytes"); |
240 | 443 | expect(result.stderr).not.toContain("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); |
241 | 444 | }); |
| 445 | + |
| 446 | + it("rejects unrelated SUT messages during the Telegram canary", async () => { |
| 447 | + const root = mkdtempSync(path.join(tmpdir(), "openclaw-telegram-rtt-causal-")); |
| 448 | + const server = await startTelegramApiServer({ canaryReplyToRequest: false }); |
| 449 | + |
| 450 | + try { |
| 451 | + const outputDir = path.join(root, "out"); |
| 452 | + const result = await runDriverAsync({ |
| 453 | + OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR: outputDir, |
| 454 | + OPENCLAW_NPM_TELEGRAM_WARM_SAMPLES: "1", |
| 455 | + OPENCLAW_QA_TELEGRAM_API_BASE_URL: server.baseUrl, |
| 456 | + OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "250", |
| 457 | + OPENCLAW_QA_TELEGRAM_SCENARIO_TIMEOUT_MS: "250", |
| 458 | + }); |
| 459 | + const summary = JSON.parse( |
| 460 | + readFileSync(path.join(outputDir, "telegram-qa-summary.json"), "utf8"), |
| 461 | + ) as { |
| 462 | + scenarios: Array<{ id: string; status: string; details?: string }>; |
| 463 | + status: string; |
| 464 | + }; |
| 465 | + const canary = summary.scenarios.find((scenario) => scenario.id === "telegram-canary"); |
| 466 | + |
| 467 | + expect(result.timedOut).toBe(false); |
| 468 | + expect(result.status).not.toBe(0); |
| 469 | + expect(summary.status).toBe("fail"); |
| 470 | + expect(canary).toMatchObject({ |
| 471 | + id: "telegram-canary", |
| 472 | + status: "fail", |
| 473 | + }); |
| 474 | + expect(canary?.details).toContain("timed out"); |
| 475 | + } finally { |
| 476 | + await server.close(); |
| 477 | + rmSync(root, { force: true, recursive: true }); |
| 478 | + } |
| 479 | + }); |
| 480 | + |
| 481 | + it("accepts reply-threaded SUT messages during the Telegram canary", async () => { |
| 482 | + const root = mkdtempSync(path.join(tmpdir(), "openclaw-telegram-rtt-causal-")); |
| 483 | + const server = await startTelegramApiServer({ canaryReplyToRequest: true }); |
| 484 | + |
| 485 | + try { |
| 486 | + const outputDir = path.join(root, "out"); |
| 487 | + const result = await runDriverAsync({ |
| 488 | + OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR: outputDir, |
| 489 | + OPENCLAW_NPM_TELEGRAM_WARM_SAMPLES: "1", |
| 490 | + OPENCLAW_QA_TELEGRAM_API_BASE_URL: server.baseUrl, |
| 491 | + OPENCLAW_QA_TELEGRAM_CANARY_TIMEOUT_MS: "1000", |
| 492 | + OPENCLAW_QA_TELEGRAM_SCENARIO_TIMEOUT_MS: "1000", |
| 493 | + }); |
| 494 | + const summary = JSON.parse( |
| 495 | + readFileSync(path.join(outputDir, "telegram-qa-summary.json"), "utf8"), |
| 496 | + ) as { |
| 497 | + scenarios: Array<{ id: string; status: string }>; |
| 498 | + status: string; |
| 499 | + }; |
| 500 | + |
| 501 | + expect(result).toMatchObject({ |
| 502 | + signal: null, |
| 503 | + status: 0, |
| 504 | + timedOut: false, |
| 505 | + }); |
| 506 | + expect(summary.status).toBe("pass"); |
| 507 | + expect(summary.scenarios).toEqual( |
| 508 | + expect.arrayContaining([ |
| 509 | + expect.objectContaining({ id: "telegram-canary", status: "pass" }), |
| 510 | + expect.objectContaining({ id: "telegram-mentioned-message-reply", status: "pass" }), |
| 511 | + ]), |
| 512 | + ); |
| 513 | + } finally { |
| 514 | + await server.close(); |
| 515 | + rmSync(root, { force: true, recursive: true }); |
| 516 | + } |
| 517 | + }); |
242 | 518 | }); |
0 commit comments