ctb 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctb",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -180,6 +180,7 @@ describe("CLI argument parsing", () => {
180
180
  dir?: string;
181
181
  help?: boolean;
182
182
  version?: boolean;
183
+ tut?: boolean;
183
184
  } {
184
185
  const options: {
185
186
  token?: string;
@@ -187,6 +188,7 @@ describe("CLI argument parsing", () => {
187
188
  dir?: string;
188
189
  help?: boolean;
189
190
  version?: boolean;
191
+ tut?: boolean;
190
192
  } = {};
191
193
 
192
194
  for (const arg of args) {
@@ -194,6 +196,8 @@ describe("CLI argument parsing", () => {
194
196
  options.help = true;
195
197
  } else if (arg === "--version" || arg === "-v") {
196
198
  options.version = true;
199
+ } else if (arg === "tut" || arg === "tutorial") {
200
+ options.tut = true;
197
201
  } else if (arg.startsWith("--token=")) {
198
202
  options.token = arg.slice(8);
199
203
  } else if (arg.startsWith("--users=")) {
@@ -222,6 +226,14 @@ describe("CLI argument parsing", () => {
222
226
  expect(parseArgs(["-v"])).toEqual({ version: true });
223
227
  });
224
228
 
229
+ test("parses tut command", () => {
230
+ expect(parseArgs(["tut"])).toEqual({ tut: true });
231
+ });
232
+
233
+ test("parses tutorial command", () => {
234
+ expect(parseArgs(["tutorial"])).toEqual({ tut: true });
235
+ });
236
+
225
237
  test("parses --token option", () => {
226
238
  const result = parseArgs(["--token=my-bot-token"]);
227
239
  expect(result.token).toBe("my-bot-token");
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Unit tests for error formatting.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { formatUserError } from "../errors";
7
+
8
+ describe("formatUserError", () => {
9
+ test("formats timeout error", () => {
10
+ const msg = formatUserError(new Error("Query timeout (180s > 180s limit)"));
11
+ expect(msg).toContain("took too long");
12
+ expect(msg).not.toContain("timeout");
13
+ });
14
+
15
+ test("formats rate limit error", () => {
16
+ const msg = formatUserError(new Error("Too Many Requests: retry after 5"));
17
+ expect(msg).toContain("busy");
18
+ });
19
+
20
+ test("formats network error", () => {
21
+ const msg = formatUserError(new Error("ETIMEDOUT"));
22
+ expect(msg.toLowerCase()).toContain("connection");
23
+ });
24
+
25
+ test("formats generic error with truncation", () => {
26
+ const longError = "A".repeat(300);
27
+ const msg = formatUserError(new Error(longError));
28
+ expect(msg.length).toBeLessThan(250);
29
+ });
30
+
31
+ test("formats cancelled/aborted error", () => {
32
+ const msg = formatUserError(new Error("Request was cancelled by user"));
33
+ expect(msg).toContain("cancelled");
34
+ });
35
+
36
+ test("formats unsafe command error", () => {
37
+ const msg = formatUserError(new Error("unsafe command detected"));
38
+ expect(msg).toContain("safety");
39
+ });
40
+
41
+ test("formats file access error", () => {
42
+ const msg = formatUserError(new Error("outside allowed paths"));
43
+ expect(msg).toContain("file location");
44
+ });
45
+
46
+ test("formats authentication error", () => {
47
+ const msg = formatUserError(new Error("401 unauthorized"));
48
+ expect(msg).toContain("Authentication");
49
+ });
50
+
51
+ test("formats ECONNRESET error", () => {
52
+ const msg = formatUserError(new Error("ECONNRESET"));
53
+ expect(msg).toContain("Connection");
54
+ });
55
+
56
+ test("handles error with empty message", () => {
57
+ const msg = formatUserError(new Error(""));
58
+ expect(msg).toContain("Error");
59
+ });
60
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Unit tests for event emitter module.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { botEvents } from "../events";
7
+
8
+ describe("BotEvents", () => {
9
+ test("emits and receives events", () => {
10
+ let received = false;
11
+ const unsubscribe = botEvents.on("sessionRunning", (running) => {
12
+ received = running;
13
+ });
14
+
15
+ botEvents.emit("sessionRunning", true);
16
+ expect(received).toBe(true);
17
+
18
+ unsubscribe();
19
+ });
20
+
21
+ test("unsubscribe stops receiving events", () => {
22
+ let count = 0;
23
+ const unsubscribe = botEvents.on("sessionRunning", () => {
24
+ count++;
25
+ });
26
+
27
+ botEvents.emit("sessionRunning", true);
28
+ expect(count).toBe(1);
29
+
30
+ unsubscribe();
31
+
32
+ botEvents.emit("sessionRunning", true);
33
+ expect(count).toBe(1);
34
+ });
35
+
36
+ test("getSessionState returns current state", () => {
37
+ botEvents.emit("sessionRunning", false);
38
+ expect(botEvents.getSessionState()).toBe(false);
39
+
40
+ botEvents.emit("sessionRunning", true);
41
+ expect(botEvents.getSessionState()).toBe(true);
42
+ });
43
+ });
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Unit tests for session module - model, cost, thinking, and plan mode features.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, test } from "bun:test";
6
+ import { ClaudeSession, getThinkingLevel } from "../session";
7
+
8
+ describe("getThinkingLevel", () => {
9
+ // Default keywords from config.ts:
10
+ // THINKING_KEYWORDS: "think,pensa,ragiona"
11
+ // THINKING_DEEP_KEYWORDS: "ultrathink,think hard,pensa bene"
12
+
13
+ describe("deep thinking triggers", () => {
14
+ test("triggers on 'think hard'", () => {
15
+ expect(getThinkingLevel("Please think hard about this")).toBe(50000);
16
+ });
17
+
18
+ test("triggers on 'ultrathink'", () => {
19
+ expect(getThinkingLevel("ultrathink about the problem")).toBe(50000);
20
+ });
21
+
22
+ test("triggers on 'pensa bene' (Italian)", () => {
23
+ expect(getThinkingLevel("pensa bene prima di rispondere")).toBe(50000);
24
+ });
25
+
26
+ test("is case insensitive", () => {
27
+ expect(getThinkingLevel("THINK HARD about this")).toBe(50000);
28
+ expect(getThinkingLevel("ULTRATHINK")).toBe(50000);
29
+ });
30
+ });
31
+
32
+ describe("normal thinking triggers", () => {
33
+ test("triggers on 'think'", () => {
34
+ expect(getThinkingLevel("Let me think about this")).toBe(10000);
35
+ });
36
+
37
+ test("triggers on 'pensa' (Italian)", () => {
38
+ expect(getThinkingLevel("pensa a questo problema")).toBe(10000);
39
+ });
40
+
41
+ test("triggers on 'ragiona' (Italian)", () => {
42
+ expect(getThinkingLevel("ragiona sul codice")).toBe(10000);
43
+ });
44
+
45
+ test("is case insensitive", () => {
46
+ expect(getThinkingLevel("THINK about it")).toBe(10000);
47
+ expect(getThinkingLevel("PENSA attentamente")).toBe(10000);
48
+ });
49
+ });
50
+
51
+ describe("no thinking triggers", () => {
52
+ test("returns 0 for simple messages", () => {
53
+ expect(getThinkingLevel("Hello")).toBe(0);
54
+ expect(getThinkingLevel("What time is it?")).toBe(0);
55
+ expect(getThinkingLevel("Show me the file")).toBe(0);
56
+ });
57
+
58
+ test("returns 0 for empty string", () => {
59
+ expect(getThinkingLevel("")).toBe(0);
60
+ });
61
+
62
+ test("returns 0 for unrelated words", () => {
63
+ expect(getThinkingLevel("analyze this code")).toBe(0);
64
+ expect(getThinkingLevel("consider the options")).toBe(0);
65
+ expect(getThinkingLevel("evaluate this approach")).toBe(0);
66
+ });
67
+ });
68
+
69
+ describe("priority", () => {
70
+ test("deep triggers take precedence over normal", () => {
71
+ // "think hard" should trigger deep (50000), not normal "think" (10000)
72
+ expect(getThinkingLevel("think hard")).toBe(50000);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("ClaudeSession", () => {
78
+ let session: ClaudeSession;
79
+
80
+ beforeEach(() => {
81
+ session = new ClaudeSession();
82
+ });
83
+
84
+ describe("model selection", () => {
85
+ test("defaults to sonnet", () => {
86
+ expect(session.currentModel).toBe("sonnet");
87
+ });
88
+
89
+ test("can be set to opus", () => {
90
+ session.currentModel = "opus";
91
+ expect(session.currentModel).toBe("opus");
92
+ });
93
+
94
+ test("can be set to haiku", () => {
95
+ session.currentModel = "haiku";
96
+ expect(session.currentModel).toBe("haiku");
97
+ });
98
+
99
+ test("can switch between models", () => {
100
+ session.currentModel = "opus";
101
+ expect(session.currentModel).toBe("opus");
102
+
103
+ session.currentModel = "haiku";
104
+ expect(session.currentModel).toBe("haiku");
105
+
106
+ session.currentModel = "sonnet";
107
+ expect(session.currentModel).toBe("sonnet");
108
+ });
109
+ });
110
+
111
+ describe("forceThinking", () => {
112
+ test("defaults to null", () => {
113
+ expect(session.forceThinking).toBeNull();
114
+ });
115
+
116
+ test("can be set to specific token count", () => {
117
+ session.forceThinking = 10000;
118
+ expect(session.forceThinking).toBe(10000);
119
+ });
120
+
121
+ test("can be set to 0 (off)", () => {
122
+ session.forceThinking = 0;
123
+ expect(session.forceThinking).toBe(0);
124
+ });
125
+
126
+ test("can be set to deep (50000)", () => {
127
+ session.forceThinking = 50000;
128
+ expect(session.forceThinking).toBe(50000);
129
+ });
130
+ });
131
+
132
+ describe("planMode", () => {
133
+ test("defaults to false", () => {
134
+ expect(session.planMode).toBe(false);
135
+ });
136
+
137
+ test("can be toggled on", () => {
138
+ session.planMode = true;
139
+ expect(session.planMode).toBe(true);
140
+ });
141
+
142
+ test("can be toggled off", () => {
143
+ session.planMode = true;
144
+ session.planMode = false;
145
+ expect(session.planMode).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("token usage tracking", () => {
150
+ test("starts with zero totals", () => {
151
+ expect(session.totalInputTokens).toBe(0);
152
+ expect(session.totalOutputTokens).toBe(0);
153
+ expect(session.totalCacheReadTokens).toBe(0);
154
+ });
155
+
156
+ test("can accumulate input tokens", () => {
157
+ session.totalInputTokens += 1000;
158
+ session.totalInputTokens += 500;
159
+ expect(session.totalInputTokens).toBe(1500);
160
+ });
161
+
162
+ test("can accumulate output tokens", () => {
163
+ session.totalOutputTokens += 2000;
164
+ session.totalOutputTokens += 800;
165
+ expect(session.totalOutputTokens).toBe(2800);
166
+ });
167
+
168
+ test("can accumulate cache read tokens", () => {
169
+ session.totalCacheReadTokens += 500;
170
+ session.totalCacheReadTokens += 300;
171
+ expect(session.totalCacheReadTokens).toBe(800);
172
+ });
173
+ });
174
+
175
+ describe("estimateCost", () => {
176
+ describe("sonnet pricing", () => {
177
+ test("calculates zero cost for zero tokens", () => {
178
+ const cost = session.estimateCost();
179
+ expect(cost.inputCost).toBe(0);
180
+ expect(cost.outputCost).toBe(0);
181
+ expect(cost.total).toBe(0);
182
+ });
183
+
184
+ test("calculates cost for 1M input tokens", () => {
185
+ session.totalInputTokens = 1_000_000;
186
+ const cost = session.estimateCost();
187
+ expect(cost.inputCost).toBe(3); // $3 per 1M input
188
+ expect(cost.outputCost).toBe(0);
189
+ expect(cost.total).toBe(3);
190
+ });
191
+
192
+ test("calculates cost for 1M output tokens", () => {
193
+ session.totalOutputTokens = 1_000_000;
194
+ const cost = session.estimateCost();
195
+ expect(cost.inputCost).toBe(0);
196
+ expect(cost.outputCost).toBe(15); // $15 per 1M output
197
+ expect(cost.total).toBe(15);
198
+ });
199
+
200
+ test("calculates combined cost", () => {
201
+ session.totalInputTokens = 500_000;
202
+ session.totalOutputTokens = 100_000;
203
+ const cost = session.estimateCost();
204
+ expect(cost.inputCost).toBe(1.5); // $3 * 0.5
205
+ expect(cost.outputCost).toBe(1.5); // $15 * 0.1
206
+ expect(cost.total).toBe(3);
207
+ });
208
+ });
209
+
210
+ describe("opus pricing", () => {
211
+ beforeEach(() => {
212
+ session.currentModel = "opus";
213
+ });
214
+
215
+ test("calculates cost for 1M input tokens", () => {
216
+ session.totalInputTokens = 1_000_000;
217
+ const cost = session.estimateCost();
218
+ expect(cost.inputCost).toBe(15); // $15 per 1M input
219
+ });
220
+
221
+ test("calculates cost for 1M output tokens", () => {
222
+ session.totalOutputTokens = 1_000_000;
223
+ const cost = session.estimateCost();
224
+ expect(cost.outputCost).toBe(75); // $75 per 1M output
225
+ });
226
+
227
+ test("calculates combined cost", () => {
228
+ session.totalInputTokens = 100_000;
229
+ session.totalOutputTokens = 50_000;
230
+ const cost = session.estimateCost();
231
+ expect(cost.inputCost).toBe(1.5); // $15 * 0.1
232
+ expect(cost.outputCost).toBe(3.75); // $75 * 0.05
233
+ expect(cost.total).toBe(5.25);
234
+ });
235
+ });
236
+
237
+ describe("haiku pricing", () => {
238
+ beforeEach(() => {
239
+ session.currentModel = "haiku";
240
+ });
241
+
242
+ test("calculates cost for 1M input tokens", () => {
243
+ session.totalInputTokens = 1_000_000;
244
+ const cost = session.estimateCost();
245
+ expect(cost.inputCost).toBe(0.25); // $0.25 per 1M input
246
+ });
247
+
248
+ test("calculates cost for 1M output tokens", () => {
249
+ session.totalOutputTokens = 1_000_000;
250
+ const cost = session.estimateCost();
251
+ expect(cost.outputCost).toBe(1.25); // $1.25 per 1M output
252
+ });
253
+
254
+ test("calculates combined cost", () => {
255
+ session.totalInputTokens = 4_000_000;
256
+ session.totalOutputTokens = 800_000;
257
+ const cost = session.estimateCost();
258
+ expect(cost.inputCost).toBe(1); // $0.25 * 4
259
+ expect(cost.outputCost).toBe(1); // $1.25 * 0.8
260
+ expect(cost.total).toBe(2);
261
+ });
262
+ });
263
+
264
+ describe("small token counts", () => {
265
+ test("calculates fractional costs", () => {
266
+ session.totalInputTokens = 1000;
267
+ session.totalOutputTokens = 500;
268
+ const cost = session.estimateCost();
269
+ expect(cost.inputCost).toBeCloseTo(0.003, 5); // $3 * 0.001
270
+ expect(cost.outputCost).toBeCloseTo(0.0075, 5); // $15 * 0.0005
271
+ expect(cost.total).toBeCloseTo(0.0105, 5);
272
+ });
273
+ });
274
+ });
275
+
276
+ describe("kill", () => {
277
+ test("resets sessionId", async () => {
278
+ session.sessionId = "test-session-123";
279
+ await session.kill();
280
+ expect(session.sessionId).toBeNull();
281
+ });
282
+
283
+ test("resets lastActivity", async () => {
284
+ session.lastActivity = new Date();
285
+ await session.kill();
286
+ expect(session.lastActivity).toBeNull();
287
+ });
288
+
289
+ test("resets token totals", async () => {
290
+ session.totalInputTokens = 10000;
291
+ session.totalOutputTokens = 5000;
292
+ session.totalCacheReadTokens = 2000;
293
+ await session.kill();
294
+ expect(session.totalInputTokens).toBe(0);
295
+ expect(session.totalOutputTokens).toBe(0);
296
+ expect(session.totalCacheReadTokens).toBe(0);
297
+ });
298
+
299
+ test("resets planMode", async () => {
300
+ session.planMode = true;
301
+ await session.kill();
302
+ expect(session.planMode).toBe(false);
303
+ });
304
+
305
+ test("resets forceThinking", async () => {
306
+ session.forceThinking = 50000;
307
+ await session.kill();
308
+ expect(session.forceThinking).toBeNull();
309
+ });
310
+
311
+ test("preserves currentModel", async () => {
312
+ session.currentModel = "opus";
313
+ await session.kill();
314
+ expect(session.currentModel).toBe("opus");
315
+ });
316
+ });
317
+
318
+ describe("isActive", () => {
319
+ test("returns false when no sessionId", () => {
320
+ expect(session.isActive).toBe(false);
321
+ });
322
+
323
+ test("returns true when sessionId exists", () => {
324
+ session.sessionId = "test-session-123";
325
+ expect(session.isActive).toBe(true);
326
+ });
327
+ });
328
+
329
+ describe("workingDir", () => {
330
+ test("can get working directory", () => {
331
+ expect(typeof session.workingDir).toBe("string");
332
+ });
333
+
334
+ test("setWorkingDir changes directory", () => {
335
+ const newDir = "/tmp/test-dir";
336
+ session.setWorkingDir(newDir);
337
+ expect(session.workingDir).toBe(newDir);
338
+ });
339
+
340
+ test("setWorkingDir clears sessionId", () => {
341
+ session.sessionId = "test-session-123";
342
+ session.setWorkingDir("/tmp/new-dir");
343
+ expect(session.sessionId).toBeNull();
344
+ });
345
+ });
346
+
347
+ describe("stop", () => {
348
+ test("returns false when nothing is running", async () => {
349
+ const result = await session.stop();
350
+ expect(result).toBe(false);
351
+ });
352
+ });
353
+
354
+ describe("interrupt flags", () => {
355
+ test("markInterrupt and consumeInterruptFlag work together", () => {
356
+ expect(session.consumeInterruptFlag()).toBe(false);
357
+ session.markInterrupt();
358
+ expect(session.consumeInterruptFlag()).toBe(true);
359
+ expect(session.consumeInterruptFlag()).toBe(false); // Consumed
360
+ });
361
+ });
362
+
363
+ describe("processing state", () => {
364
+ test("startProcessing returns cleanup function", () => {
365
+ expect(session.isRunning).toBe(false);
366
+ const cleanup = session.startProcessing();
367
+ expect(session.isRunning).toBe(true);
368
+ cleanup();
369
+ expect(session.isRunning).toBe(false);
370
+ });
371
+ });
372
+
373
+ describe("undo/checkpointing", () => {
374
+ test("canUndo is false by default", () => {
375
+ expect(session.canUndo).toBe(false);
376
+ });
377
+
378
+ test("undoCheckpoints is 0 by default", () => {
379
+ expect(session.undoCheckpoints).toBe(0);
380
+ });
381
+
382
+ test("undo fails when no session active", async () => {
383
+ const [success, message] = await session.undo();
384
+ expect(success).toBe(false);
385
+ expect(message).toContain("No active session");
386
+ });
387
+
388
+ test("kill resets checkpoints", async () => {
389
+ // Simulate having checkpoints (by accessing private property for testing)
390
+ // @ts-expect-error - accessing private for test
391
+ session._userMessageUuids = ["uuid1", "uuid2"];
392
+ expect(session.undoCheckpoints).toBe(2);
393
+
394
+ await session.kill();
395
+ expect(session.undoCheckpoints).toBe(0);
396
+ expect(session.canUndo).toBe(false);
397
+ });
398
+ });
399
+
400
+ describe("session debouncing", () => {
401
+ test("multiple saveSession calls within debounce window only write once", async () => {
402
+ let writeCount = 0;
403
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
404
+ const originalWrite = (Bun as any).write;
405
+
406
+ // Mock Bun.write to count session.json writes
407
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
408
+ (Bun as any).write = async (...args: unknown[]) => {
409
+ if (String(args[0]).includes("session.json")) {
410
+ writeCount++;
411
+ }
412
+ return originalWrite.apply(Bun, args);
413
+ };
414
+
415
+ try {
416
+ // Set up a session ID to trigger saves
417
+ session.sessionId = "test-session-debounce-1";
418
+
419
+ // Call saveSession multiple times rapidly
420
+ // @ts-expect-error - accessing private for test
421
+ session.saveSession();
422
+ // @ts-expect-error - accessing private for test
423
+ session.saveSession();
424
+ // @ts-expect-error - accessing private for test
425
+ session.saveSession();
426
+
427
+ // Immediately after calls, write should not have happened yet (debounced)
428
+ expect(writeCount).toBe(0);
429
+
430
+ // Wait for debounce timeout (500ms + buffer)
431
+ await Bun.sleep(600);
432
+
433
+ // Now exactly one write should have occurred
434
+ expect(writeCount).toBe(1);
435
+ } finally {
436
+ // Restore original Bun.write
437
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
438
+ (Bun as any).write = originalWrite;
439
+ }
440
+ });
441
+
442
+ test("flushSession writes immediately", async () => {
443
+ let writeCount = 0;
444
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
445
+ const originalWrite = (Bun as any).write;
446
+
447
+ // Mock Bun.write to count session.json writes
448
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
449
+ (Bun as any).write = async (...args: unknown[]) => {
450
+ if (String(args[0]).includes("session.json")) {
451
+ writeCount++;
452
+ }
453
+ return originalWrite.apply(Bun, args);
454
+ };
455
+
456
+ try {
457
+ // Set up a session ID to trigger saves
458
+ session.sessionId = "test-session-flush-1";
459
+
460
+ // Call flushSession for immediate write
461
+ session.flushSession();
462
+
463
+ // Write should happen immediately (synchronously)
464
+ expect(writeCount).toBe(1);
465
+ } finally {
466
+ // Restore original Bun.write
467
+ // biome-ignore lint/suspicious/noExplicitAny: test mock requires any
468
+ (Bun as any).write = originalWrite;
469
+ }
470
+ });
471
+ });
472
+ });
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Unit tests for Telegram API utilities.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { TelegramApiError, withRetry } from "../telegram-api";
7
+
8
+ describe("withRetry", () => {
9
+ test("succeeds on first attempt", async () => {
10
+ let attempts = 0;
11
+ const result = await withRetry(async () => {
12
+ attempts++;
13
+ return "success";
14
+ });
15
+ expect(result).toBe("success");
16
+ expect(attempts).toBe(1);
17
+ });
18
+
19
+ test("retries on transient failure then succeeds", async () => {
20
+ let attempts = 0;
21
+ const result = await withRetry(
22
+ async () => {
23
+ attempts++;
24
+ if (attempts < 3) {
25
+ throw new Error("Too Many Requests: retry after 1");
26
+ }
27
+ return "success";
28
+ },
29
+ { maxRetries: 3, baseDelay: 10 },
30
+ );
31
+ expect(result).toBe("success");
32
+ expect(attempts).toBe(3);
33
+ });
34
+
35
+ test("throws after max retries", async () => {
36
+ let attempts = 0;
37
+ await expect(
38
+ withRetry(
39
+ async () => {
40
+ attempts++;
41
+ throw new Error("Too Many Requests: retry after 1");
42
+ },
43
+ { maxRetries: 2, baseDelay: 10 },
44
+ ),
45
+ ).rejects.toThrow();
46
+ expect(attempts).toBe(2);
47
+ });
48
+
49
+ test("does not retry non-transient errors", async () => {
50
+ let attempts = 0;
51
+ await expect(
52
+ withRetry(
53
+ async () => {
54
+ attempts++;
55
+ throw new Error("Bad Request: message not found");
56
+ },
57
+ { maxRetries: 3, baseDelay: 10 },
58
+ ),
59
+ ).rejects.toThrow("Bad Request");
60
+ expect(attempts).toBe(1);
61
+ });
62
+ });
63
+
64
+ describe("TelegramApiError", () => {
65
+ test("isTransient returns true for rate limit errors", () => {
66
+ const error = new TelegramApiError("Too Many Requests: retry after 5", 429);
67
+ expect(error.isTransient).toBe(true);
68
+ expect(error.retryAfter).toBe(5);
69
+ });
70
+
71
+ test("isTransient returns true for network errors", () => {
72
+ const error = new TelegramApiError("ETIMEDOUT", 0);
73
+ expect(error.isTransient).toBe(true);
74
+ });
75
+
76
+ test("isTransient returns false for bad request", () => {
77
+ const error = new TelegramApiError("Bad Request: message not found", 400);
78
+ expect(error.isTransient).toBe(false);
79
+ });
80
+ });