Skip to content

Commit cd9c643

Browse files
committed
fix(e2e): require causal telegram rtt canary
1 parent 6bfd47a commit cd9c643

4 files changed

Lines changed: 281 additions & 3 deletions

File tree

scripts/e2e/npm-telegram-rtt-config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ config.channels.telegram = {
9191
id: "TELEGRAM_BOT_TOKEN",
9292
},
9393
streaming: { mode: "off" },
94+
replyToMode: "first",
9495
dmPolicy: "allowlist",
9596
allowFrom: [driverId],
9697
defaultTo: driverId,

scripts/e2e/npm-telegram-rtt-driver.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,8 @@ async function waitForSutReply(params) {
210210
continue;
211211
}
212212
const replyMatches = message.reply_to_message?.message_id === params.requestMessageId;
213-
const anySutReplyMatches = params.allowAnySutReply;
214-
if (replyMatches || anySutReplyMatches || params.matchText) {
213+
const textMatches = params.matchText ? text.includes(params.matchText) : false;
214+
if (replyMatches || textMatches) {
215215
return message;
216216
}
217217
}
@@ -371,7 +371,7 @@ async function main() {
371371

372372
const scenarios = [];
373373
const canary = await runScenario({
374-
allowAnySutReply: true,
374+
allowAnySutReply: false,
375375
id: "telegram-canary",
376376
input: `/status@${sutMe.username}`,
377377
sutId: sutMe.id,

test/scripts/npm-telegram-rtt-driver.test.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Npm Telegram Rtt Driver tests cover npm telegram rtt driver script behavior.
22
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
33
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
45
import { tmpdir } from "node:os";
56
import path from "node:path";
67
import { setTimeout as delay } from "node:timers/promises";
@@ -103,6 +104,208 @@ type DriverCaseResult = {
103104
elapsedMs?: number;
104105
};
105106

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+
106309
async function runStalledTelegramBodyCase(): Promise<DriverCaseResult> {
107310
const root = mkdtempSync(path.join(tmpdir(), "openclaw-telegram-rtt-driver-"));
108311
const portPath = path.join(root, "port.txt");
@@ -239,4 +442,77 @@ describe("npm Telegram RTT driver", () => {
239442
expect(result.stderr).toContain("Telegram Bot API getMe response body exceeded 16 bytes");
240443
expect(result.stderr).not.toContain("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
241444
});
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+
});
242518
});

test/scripts/rtt-harness.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,7 @@ describe("RTT harness", () => {
466466
]);
467467

468468
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
469+
expect(config.channels.telegram.replyToMode).toBe("first");
469470
expect(config.channels.telegram.streaming).toEqual({ mode: "off" });
470471
expect(config.messages.groupChat.visibleReplies).toBe("automatic");
471472
});

0 commit comments

Comments
 (0)