返回 CodeWhale
lib.test.mjs
1 import test from "node:test";
2 import assert from "node:assert/strict";
3
4 import {
5 activeTurnBlock,
6 activeTurnKeyboard,
7 approvalKeyboard,
8 callbackAction,
9 commandAction,
10 controlKeyboard,
11 envFirst,
12 helpText,
13 isAllowed,
14 pairingRefusalText,
15 parseApprovalDecisionArgs,
16 parseBool,
17 parseCommand,
18 parseEnvText,
19 parseList,
20 preservedChatStateFields,
21 splitMessage,
22 stripGroupPrefix,
23 threadListKeyboard,
24 telegramIdentity,
25 telegramMarkdownV2,
26 telegramMessageBody,
27 plainTelegramText,
28 telegramPollingConflictDelayMs,
29 telegramRetryDelayMs,
30 telegramSendRetryDelayMs,
31 isTelegramMarkdownParseError,
32 looksLikePollingConflict,
33 validateBridgeConfig
34 } from "../src/lib.mjs";
35
36 test("envFirst returns first non-empty value", () => {
37 assert.equal(envFirst({ A: "", B: " value " }, "A", "B"), "value");
38 assert.equal(envFirst({ A: "x" }, "B"), "");
39 });
40
41 test("parseList trims empty values", () => {
42 assert.deepEqual(parseList(" 123, @user ,, "), ["123", "@user"]);
43 });
44
45 test("parseBool accepts common truthy values", () => {
46 assert.equal(parseBool("yes"), true);
47 assert.equal(parseBool("0", true), false);
48 assert.equal(parseBool(undefined, true), true);
49 });
50
51 test("parseEnvText handles comments, export, and quoted values", () => {
52 assert.deepEqual(
53 parseEnvText(`
54 # ignored
55 export TELEGRAM_GROUP_PREFIX="/cw"
56 CODEWHALE_WORKSPACE='/opt/whalebro'
57 `),
58 {
59 TELEGRAM_GROUP_PREFIX: "/cw",
60 CODEWHALE_WORKSPACE: "/opt/whalebro"
61 }
62 );
63 });
64
65 test("telegramIdentity extracts chat and sender identifiers", () => {
66 const identity = telegramIdentity({
67 update_id: 10,
68 message: {
69 message_id: 20,
70 text: "hello",
71 chat: { id: -1001, type: "supergroup" },
72 from: { id: 42, username: "hunter", first_name: "Hunter" }
73 }
74 });
75 assert.deepEqual(identity, {
76 updateId: 10,
77 chatId: "-1001",
78 messageId: "20",
79 chatType: "supergroup",
80 userId: "42",
81 username: "@hunter",
82 firstName: "Hunter",
83 text: "hello",
84 isBot: false
85 });
86 });
87
88 test("stripGroupPrefix requires prefix in Telegram groups", () => {
89 assert.deepEqual(
90 stripGroupPrefix("/cw inspect this", {
91 chatType: "group",
92 requirePrefix: true,
93 prefix: "/cw"
94 }),
95 { accepted: true, text: "inspect this" }
96 );
97 assert.equal(
98 stripGroupPrefix("inspect this", {
99 chatType: "group",
100 requirePrefix: true,
101 prefix: "/cw"
102 }).accepted,
103 false
104 );
105 });
106
107 test("stripGroupPrefix accepts private chat text without group prefix", () => {
108 assert.deepEqual(
109 stripGroupPrefix("inspect this", {
110 chatType: "private",
111 requirePrefix: true,
112 prefix: "/cw"
113 }),
114 { accepted: true, text: "inspect this" }
115 );
116 });
117
118 test("stripGroupPrefix accepts Telegram channel text without group prefix", () => {
119 assert.deepEqual(
120 stripGroupPrefix("inspect this", {
121 chatType: "channel",
122 requirePrefix: true,
123 prefix: "/cw"
124 }),
125 { accepted: true, text: "inspect this" }
126 );
127 });
128
129 test("parseCommand handles Telegram bot mentions", () => {
130 assert.deepEqual(parseCommand("hello"), { name: "prompt", args: "hello" });
131 assert.deepEqual(parseCommand("/allow@CodeWhaleBot abc remember"), {
132 name: "allow",
133 args: "abc remember"
134 });
135 });
136
137 test("commandAction maps bridge commands and falls back to prompts", () => {
138 assert.deepEqual(commandAction(parseCommand("/menu")), { kind: "menu" });
139 assert.deepEqual(commandAction(parseCommand("/status")), { kind: "status" });
140 assert.deepEqual(commandAction(parseCommand("/resume thread-1")), {
141 kind: "resume",
142 threadId: "thread-1"
143 });
144 assert.deepEqual(commandAction(parseCommand("/model arcee-trinity")), {
145 kind: "set_model",
146 modelName: "arcee-trinity"
147 });
148 assert.deepEqual(commandAction(parseCommand("/unknown value")), {
149 kind: "prompt",
150 prompt: "/unknown value"
151 });
152 });
153
154 test("helpText documents per-chat model switching", () => {
155 assert.match(helpText(), /\/model <name\|default>/);
156 assert.match(helpText(), /\/menu/);
157 });
158
159 test("control keyboards expose modal actions", () => {
160 assert.deepEqual(controlKeyboard().inline_keyboard[0][0], {
161 text: "Status",
162 callback_data: "cw:status"
163 });
164 assert.deepEqual(activeTurnKeyboard().inline_keyboard[0][1], {
165 text: "Interrupt",
166 callback_data: "cw:interrupt"
167 });
168 assert.deepEqual(approvalKeyboard("tok1").inline_keyboard[1][0], {
169 text: "Deny",
170 callback_data: "cw:act:tok1:deny"
171 });
172 assert.deepEqual(threadListKeyboard([{ token: "t1", label: "Resume 1" }]).inline_keyboard[0][0], {
173 text: "Resume 1",
174 callback_data: "cw:act:t1"
175 });
176 });
177
178 test("callbackAction parses modal callback payloads", () => {
179 assert.deepEqual(callbackAction("cw:status"), { kind: "status" });
180 assert.deepEqual(callbackAction("cw:model:default"), {
181 kind: "set_model",
182 modelName: "default"
183 });
184 assert.deepEqual(callbackAction("cw:act:tok1:remember"), {
185 kind: "stored_action",
186 token: "tok1",
187 suffix: "remember"
188 });
189 assert.equal(callbackAction("unknown"), null);
190 });
191
192 test("preservedChatStateFields carries model across state replacement", () => {
193 assert.deepEqual(
194 preservedChatStateFields({
195 threadId: "old-thread",
196 model: "mimo-v2.5-pro",
197 activeTurnId: "turn-1"
198 }),
199 {
200 model: "mimo-v2.5-pro"
201 }
202 );
203 assert.deepEqual(preservedChatStateFields({ model: null }), { model: null });
204 });
205
206 test("parseApprovalDecisionArgs extracts remember flag", () => {
207 assert.deepEqual(parseApprovalDecisionArgs("ap_123 remember"), {
208 approvalId: "ap_123",
209 remember: true
210 });
211 assert.deepEqual(parseApprovalDecisionArgs(""), { approvalId: "", remember: false });
212 });
213
214 test("isAllowed checks Telegram chat/user/username identifiers", () => {
215 assert.equal(
216 isAllowed({ chatId: "-1001", userId: "42", username: "@hunter" }, ["42"], false),
217 true
218 );
219 assert.equal(isAllowed({ chatId: "-1001" }, [], false), false);
220 assert.equal(isAllowed({ chatId: "-1001" }, [], true), true);
221 });
222
223 test("pairingRefusalText includes allowlist identifiers", () => {
224 const body = pairingRefusalText({
225 chatId: "-1001",
226 userId: "42",
227 username: "@hunter"
228 });
229 assert.match(body, /chat_id=-1001/);
230 assert.match(body, /user_id=42/);
231 assert.match(body, /username=@hunter/);
232 });
233
234 test("activeTurnBlock reports active queued or in-progress turn", () => {
235 assert.equal(activeTurnBlock({ turns: [{ id: "done", status: "completed" }] }), null);
236 assert.deepEqual(
237 activeTurnBlock({
238 turns: [
239 { id: "old", status: "completed" },
240 { id: "turn-2", status: "queued" }
241 ]
242 }),
243 {
244 turnId: "turn-2",
245 message: "Thread already has active turn turn-2. Wait for it to finish or send /interrupt."
246 }
247 );
248 });
249
250 test("splitMessage chunks long text without splitting surrogate pairs", () => {
251 assert.deepEqual(splitMessage("a🧪b", 2), ["a🧪", "b"]);
252 });
253
254 test("telegramMarkdownV2 escapes text while preserving useful markdown", () => {
255 assert.equal(
256 telegramMarkdownV2("**Build** passed for [CI](https://example.com/a_(b))."),
257 "*Build* passed for [CI](https://example.com/a_(b\\))\\."
258 );
259 assert.equal(telegramMarkdownV2("Use `cargo test -p codewhale`."), "Use `cargo test -p codewhale`\\.");
260 assert.equal(telegramMarkdownV2("Path C:\\tmp\\file"), "Path C:\\\\tmp\\\\file");
261 assert.equal(
262 telegramMarkdownV2("```rust\nfn main() { println!(\"hi\"); }\n```"),
263 "```rust\nfn main() { println!(\"hi\"); }\n```"
264 );
265 });
266
267 test("telegramMarkdownV2 rewrites pipe tables into phone-readable bullets", () => {
268 assert.equal(
269 telegramMarkdownV2("| Gate | Result |\n| --- | --- |\n| Lint | Pass |\n| Tests | Fail |"),
270 "*Gate / Result*\n• Gate: Lint; Result: Pass\n• Gate: Tests; Result: Fail"
271 );
272 });
273
274 test("telegram message bodies can fall back from MarkdownV2 to plain text", () => {
275 assert.deepEqual(telegramMessageBody("**Done**", { markdown: true }), {
276 text: "*Done*",
277 parse_mode: "MarkdownV2"
278 });
279 assert.deepEqual(telegramMessageBody("!!!!", { markdown: true, maxChars: 4 }), {
280 text: "!!!!"
281 });
282 assert.deepEqual(telegramMessageBody("**Done**", { markdown: false }), {
283 text: "Done"
284 });
285 assert.equal(plainTelegramText("[CI](https://example.com) **passed**"), "CI (https://example.com) passed");
286 assert.equal(
287 isTelegramMarkdownParseError({ errorCode: 400, description: "Bad Request: can't parse entities" }),
288 true
289 );
290 });
291
292 test("telegramRetryDelayMs honors retry_after", () => {
293 assert.equal(telegramRetryDelayMs({ parameters: { retry_after: 2 } }), 2000);
294 });
295
296 test("telegramPollingConflictDelayMs escalates before going fatal", () => {
297 assert.deepEqual(
298 [0, 1, 2, 3, 4, 5].map((attempt) => telegramPollingConflictDelayMs(attempt)),
299 [15000, 25000, 35000, 45000, 55000, null]
300 );
301 });
302
303 test("telegramSendRetryDelayMs retries only safe send failures", () => {
304 assert.equal(
305 telegramSendRetryDelayMs({ errorCode: 429, parameters: { retry_after: 3 } }, 0),
306 3000
307 );
308 assert.equal(
309 telegramSendRetryDelayMs({ errorCode: 429, parameters: { retry_after: 3 } }, 3),
310 null
311 );
312 assert.equal(telegramSendRetryDelayMs(new TypeError("fetch failed"), 0), 1000);
313 assert.equal(telegramSendRetryDelayMs(new TypeError("fetch failed"), 1), 2000);
314 assert.equal(telegramSendRetryDelayMs(new TypeError("fetch failed"), 2), null);
315 assert.equal(telegramSendRetryDelayMs({ name: "AbortError" }, 0), null);
316 assert.equal(telegramSendRetryDelayMs({ errorCode: 500 }, 0), null);
317 });
318
319 test("looksLikePollingConflict detects Telegram 409 conflicts", () => {
320 assert.equal(looksLikePollingConflict({ errorCode: 409 }), true);
321 assert.equal(
322 looksLikePollingConflict({
323 message: "Conflict: terminated by other getUpdates request"
324 }),
325 true
326 );
327 });
328
329 test("validateBridgeConfig accepts locked-down whalebro DM config", () => {
330 const result = validateBridgeConfig(
331 {
332 TELEGRAM_BOT_TOKEN: "123456:token",
333 CODEWHALE_RUNTIME_URL: "http://127.0.0.1:7878",
334 CODEWHALE_RUNTIME_TOKEN: "token-a",
335 CODEWHALE_WORKSPACE: "/opt/whalebro",
336 TELEGRAM_CHAT_ALLOWLIST: "42",
337 TELEGRAM_ALLOW_UNLISTED: "false",
338 TELEGRAM_THREAD_MAP_PATH: "/var/lib/codewhale-telegram-bridge/thread-map.json",
339 TELEGRAM_ALLOW_GROUPS: "false",
340 TELEGRAM_REQUIRE_PREFIX_IN_GROUP: "true"
341 },
342 {
343 workspaceRoot: "/opt/whalebro",
344 runtimeEnv: {
345 CODEWHALE_RUNTIME_TOKEN: "token-a",
346 CODEWHALE_PROVIDER: "arcee",
347 CODEWHALE_RUNTIME_PORT: "7878"
348 }
349 }
350 );
351 assert.equal(result.ok, true);
352 assert.equal(result.errors.length, 0);
353 });
354
355 test("validateBridgeConfig rejects unsafe group pairing and token mismatch", () => {
356 const result = validateBridgeConfig(
357 {
358 TELEGRAM_BOT_TOKEN: "123456:token",
359 CODEWHALE_RUNTIME_URL: "http://127.0.0.1:7878",
360 CODEWHALE_RUNTIME_TOKEN: "bridge-token",
361 CODEWHALE_WORKSPACE: "/opt/whalebro",
362 TELEGRAM_ALLOW_UNLISTED: "true",
363 TELEGRAM_THREAD_MAP_PATH: "/var/lib/codewhale-telegram-bridge/thread-map.json",
364 TELEGRAM_ALLOW_GROUPS: "true",
365 TELEGRAM_REQUIRE_PREFIX_IN_GROUP: "false"
366 },
367 {
368 workspaceRoot: "/opt/whalebro",
369 runtimeEnv: {
370 CODEWHALE_RUNTIME_TOKEN: "runtime-token",
371 CODEWHALE_PROVIDER: "arcee"
372 }
373 }
374 );
375 assert.equal(result.ok, false);
376 assert.match(
377 result.errors.map((item) => item.code).join(","),
378 /open_group_control/
379 );
380 assert.match(result.errors.map((item) => item.code).join(","), /token_mismatch/);
381 assert.match(result.warnings.map((item) => item.code).join(","), /group_without_prefix/);
382 });
383
383 lines Plain Text