talon-agent 1.0.0 → 1.2.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +15 -11
  4. package/prompts/dream.md +7 -3
  5. package/prompts/heartbeat.md +30 -0
  6. package/prompts/identity.md +1 -0
  7. package/prompts/teams.md +3 -0
  8. package/prompts/telegram.md +1 -0
  9. package/src/__tests__/chat-settings.test.ts +108 -2
  10. package/src/__tests__/cleanup-registry.test.ts +58 -0
  11. package/src/__tests__/config.test.ts +118 -52
  12. package/src/__tests__/cron-store-extended.test.ts +661 -0
  13. package/src/__tests__/cron-store.test.ts +145 -11
  14. package/src/__tests__/daily-log.test.ts +224 -13
  15. package/src/__tests__/dispatcher.test.ts +424 -23
  16. package/src/__tests__/dream.test.ts +1028 -0
  17. package/src/__tests__/errors-extended.test.ts +428 -0
  18. package/src/__tests__/errors.test.ts +95 -3
  19. package/src/__tests__/fuzz.test.ts +87 -15
  20. package/src/__tests__/gateway-actions.test.ts +1174 -433
  21. package/src/__tests__/gateway-http.test.ts +210 -19
  22. package/src/__tests__/gateway-retry.test.ts +359 -0
  23. package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
  24. package/src/__tests__/graph.test.ts +830 -0
  25. package/src/__tests__/handlers-stream.test.ts +208 -0
  26. package/src/__tests__/handlers.test.ts +2539 -70
  27. package/src/__tests__/heartbeat.test.ts +364 -0
  28. package/src/__tests__/history-extended.test.ts +775 -0
  29. package/src/__tests__/history-persistence.test.ts +74 -19
  30. package/src/__tests__/history.test.ts +113 -79
  31. package/src/__tests__/integration.test.ts +43 -8
  32. package/src/__tests__/log-init.test.ts +129 -0
  33. package/src/__tests__/log.test.ts +23 -5
  34. package/src/__tests__/media-index.test.ts +317 -35
  35. package/src/__tests__/plugin.test.ts +314 -0
  36. package/src/__tests__/prompt-builder-extended.test.ts +296 -0
  37. package/src/__tests__/prompt-builder.test.ts +44 -9
  38. package/src/__tests__/sessions.test.ts +258 -4
  39. package/src/__tests__/storage-save-errors.test.ts +342 -0
  40. package/src/__tests__/teams-frontend.test.ts +526 -31
  41. package/src/__tests__/telegram-formatting.test.ts +82 -0
  42. package/src/__tests__/terminal-commands.test.ts +208 -1
  43. package/src/__tests__/terminal-renderer.test.ts +223 -0
  44. package/src/__tests__/time.test.ts +107 -0
  45. package/src/__tests__/workspace-migrate.test.ts +256 -0
  46. package/src/__tests__/workspace.test.ts +63 -1
  47. package/src/backend/claude-sdk/tools.ts +64 -18
  48. package/src/bootstrap.ts +14 -14
  49. package/src/cli.ts +440 -125
  50. package/src/core/cron.ts +20 -5
  51. package/src/core/dispatcher.ts +27 -9
  52. package/src/core/dream.ts +79 -24
  53. package/src/core/errors.ts +12 -2
  54. package/src/core/gateway-actions.ts +182 -46
  55. package/src/core/gateway.ts +93 -41
  56. package/src/core/heartbeat.ts +515 -0
  57. package/src/core/plugin.ts +1 -1
  58. package/src/core/prompt-builder.ts +1 -4
  59. package/src/core/pulse.ts +4 -3
  60. package/src/frontend/teams/actions.ts +3 -1
  61. package/src/frontend/teams/formatting.ts +47 -8
  62. package/src/frontend/teams/graph.ts +35 -11
  63. package/src/frontend/teams/index.ts +155 -57
  64. package/src/frontend/teams/tools.ts +4 -6
  65. package/src/frontend/telegram/actions.ts +358 -82
  66. package/src/frontend/telegram/admin.ts +162 -72
  67. package/src/frontend/telegram/callbacks.ts +16 -10
  68. package/src/frontend/telegram/commands.ts +37 -21
  69. package/src/frontend/telegram/formatting.ts +2 -4
  70. package/src/frontend/telegram/handlers.ts +262 -66
  71. package/src/frontend/telegram/index.ts +39 -14
  72. package/src/frontend/telegram/middleware.ts +14 -4
  73. package/src/frontend/telegram/userbot.ts +16 -4
  74. package/src/frontend/terminal/renderer.ts +1 -4
  75. package/src/index.ts +28 -4
  76. package/src/storage/chat-settings.ts +32 -9
  77. package/src/storage/cron-store.ts +53 -11
  78. package/src/storage/daily-log.ts +72 -19
  79. package/src/storage/history.ts +39 -21
  80. package/src/storage/media-index.ts +37 -12
  81. package/src/storage/sessions.ts +3 -2
  82. package/src/util/cleanup-registry.ts +34 -0
  83. package/src/util/config.ts +85 -23
  84. package/src/util/log.ts +47 -17
  85. package/src/util/paths.ts +10 -0
  86. package/src/util/time.ts +29 -6
  87. package/src/util/watchdog.ts +5 -1
  88. package/src/util/workspace.ts +51 -10
@@ -1,35 +1,42 @@
1
- import { describe, it, expect, vi } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
 
3
- vi.mock("../../core/dispatcher.js", () => ({
4
- execute: vi.fn(),
3
+ const executeMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("../core/dispatcher.js", () => ({
6
+ execute: executeMock,
5
7
  }));
6
- vi.mock("../../core/prompt-builder.js", () => ({
8
+ vi.mock("../core/prompt-builder.js", () => ({
7
9
  enrichDMPrompt: vi.fn((p: string) => p),
8
10
  enrichGroupPrompt: vi.fn((p: string) => p),
9
11
  }));
10
- vi.mock("../../storage/daily-log.js", () => ({
12
+ vi.mock("../storage/daily-log.js", () => ({
11
13
  appendDailyLog: vi.fn(),
14
+ appendDailyLogResponse: vi.fn(),
12
15
  }));
13
- vi.mock("../../util/watchdog.js", () => ({
16
+ vi.mock("../util/watchdog.js", () => ({
14
17
  recordMessageProcessed: vi.fn(),
15
18
  recordError: vi.fn(),
16
19
  }));
17
- vi.mock("../../util/log.js", () => ({
20
+ vi.mock("../util/log.js", () => ({
18
21
  log: vi.fn(),
19
22
  logError: vi.fn(),
20
23
  logWarn: vi.fn(),
21
24
  logDebug: vi.fn(),
22
25
  }));
23
- vi.mock("../../core/errors.js", () => ({
24
- classify: vi.fn((e: unknown) => e),
25
- friendlyMessage: vi.fn(() => "error"),
26
+ vi.mock("../core/errors.js", () => ({
27
+ classify: vi.fn((e: unknown) => ({
28
+ reason: "error",
29
+ message: String(e),
30
+ retryable: false,
31
+ })),
32
+ friendlyMessage: vi.fn(() => "An error occurred"),
26
33
  }));
27
34
 
28
- vi.mock("../../storage/history.js", () => ({
35
+ vi.mock("../storage/history.js", () => ({
29
36
  setMessageFilePath: vi.fn(),
30
37
  getRecentBySenderId: vi.fn(() => []),
31
38
  }));
32
- vi.mock("../../storage/media-index.js", () => ({
39
+ vi.mock("../storage/media-index.js", () => ({
33
40
  addMedia: vi.fn(),
34
41
  }));
35
42
  vi.mock("node:fs", () => ({
@@ -38,20 +45,28 @@ vi.mock("node:fs", () => ({
38
45
  existsSync: vi.fn(() => true),
39
46
  }));
40
47
 
41
- const { shouldHandleInGroup, getSenderName, getReplyContext, getForwardContext } = await import(
42
- "../frontend/telegram/handlers.js"
43
- );
48
+ const {
49
+ shouldHandleInGroup,
50
+ getSenderName,
51
+ getReplyContext,
52
+ getForwardContext,
53
+ } = await import("../frontend/telegram/handlers.js");
44
54
 
45
55
  describe("shouldHandleInGroup", () => {
46
- const makeCtx = (overrides: {
47
- type?: string;
48
- text?: string;
49
- username?: string;
50
- replyFromId?: number;
51
- botId?: number;
52
- } = {}) => ({
56
+ const makeCtx = (
57
+ overrides: {
58
+ type?: string;
59
+ text?: string;
60
+ username?: string;
61
+ replyFromId?: number;
62
+ botId?: number;
63
+ } = {},
64
+ ) => ({
53
65
  chat: { type: overrides.type ?? "supergroup" },
54
- me: { id: overrides.botId ?? 999, username: overrides.username ?? "testbot" },
66
+ me: {
67
+ id: overrides.botId ?? 999,
68
+ username: overrides.username ?? "testbot",
69
+ },
55
70
  message: {
56
71
  text: overrides.text ?? "",
57
72
  caption: undefined,
@@ -66,11 +81,15 @@ describe("shouldHandleInGroup", () => {
66
81
  makeCtx(overrides) as any;
67
82
 
68
83
  it("returns true for DMs (not a group)", () => {
69
- expect(shouldHandleInGroup({ ...ctx(), chat: { type: "private" } })).toBe(true);
84
+ expect(shouldHandleInGroup({ ...ctx(), chat: { type: "private" } })).toBe(
85
+ true,
86
+ );
70
87
  });
71
88
 
72
89
  it("returns true when bot is mentioned with @", () => {
73
- expect(shouldHandleInGroup(ctx({ text: "hey @testbot what's up" }))).toBe(true);
90
+ expect(shouldHandleInGroup(ctx({ text: "hey @testbot what's up" }))).toBe(
91
+ true,
92
+ );
74
93
  });
75
94
 
76
95
  it("returns true when bot is mentioned case-insensitively", () => {
@@ -78,7 +97,9 @@ describe("shouldHandleInGroup", () => {
78
97
  });
79
98
 
80
99
  it("returns false when a different bot is mentioned", () => {
81
- expect(shouldHandleInGroup(ctx({ text: "hey @testbot123 what's up" }))).toBe(false);
100
+ expect(
101
+ shouldHandleInGroup(ctx({ text: "hey @testbot123 what's up" })),
102
+ ).toBe(false);
82
103
  });
83
104
 
84
105
  it("returns false when bot name appears without @", () => {
@@ -86,11 +107,15 @@ describe("shouldHandleInGroup", () => {
86
107
  });
87
108
 
88
109
  it("returns true when replying to the bot", () => {
89
- expect(shouldHandleInGroup(ctx({ replyFromId: 999, botId: 999 }))).toBe(true);
110
+ expect(shouldHandleInGroup(ctx({ replyFromId: 999, botId: 999 }))).toBe(
111
+ true,
112
+ );
90
113
  });
91
114
 
92
115
  it("returns false when replying to someone else", () => {
93
- expect(shouldHandleInGroup(ctx({ replyFromId: 123, botId: 999 }))).toBe(false);
116
+ expect(shouldHandleInGroup(ctx({ replyFromId: 123, botId: 999 }))).toBe(
117
+ false,
118
+ );
94
119
  });
95
120
 
96
121
  it("returns false for plain group message without mention or reply", () => {
@@ -98,7 +123,9 @@ describe("shouldHandleInGroup", () => {
98
123
  });
99
124
 
100
125
  it("handles @mention at end of message", () => {
101
- expect(shouldHandleInGroup(ctx({ text: "what do you think @testbot" }))).toBe(true);
126
+ expect(
127
+ shouldHandleInGroup(ctx({ text: "what do you think @testbot" })),
128
+ ).toBe(true);
102
129
  });
103
130
 
104
131
  it("handles @mention with punctuation after", () => {
@@ -108,7 +135,9 @@ describe("shouldHandleInGroup", () => {
108
135
 
109
136
  describe("getSenderName", () => {
110
137
  it("returns first + last name", () => {
111
- expect(getSenderName({ first_name: "John", last_name: "Doe" })).toBe("John Doe");
138
+ expect(getSenderName({ first_name: "John", last_name: "Doe" })).toBe(
139
+ "John Doe",
140
+ );
112
141
  });
113
142
 
114
143
  it("returns first name only when no last name", () => {
@@ -137,7 +166,11 @@ describe("getReplyContext", () => {
137
166
 
138
167
  it("includes author, text, and message_id for reply to others", () => {
139
168
  const result = getReplyContext(
140
- { message_id: 42, from: { id: 123, first_name: "Alice" }, text: "original message" },
169
+ {
170
+ message_id: 42,
171
+ from: { id: 123, first_name: "Alice" },
172
+ text: "original message",
173
+ },
141
174
  999,
142
175
  );
143
176
  expect(result).toContain("Alice");
@@ -215,7 +248,11 @@ describe("getReplyContext — edge cases", () => {
215
248
 
216
249
  it("includes message_id and media type even without text", () => {
217
250
  const result = getReplyContext(
218
- { message_id: 100, from: { id: 123, first_name: "Dan" }, photo: [{}] as unknown[] },
251
+ {
252
+ message_id: 100,
253
+ from: { id: 123, first_name: "Dan" },
254
+ photo: [{}] as unknown[],
255
+ },
219
256
  999,
220
257
  );
221
258
  expect(result).toContain("msg_id:100");
@@ -233,45 +270,108 @@ describe("getReplyContext — edge cases", () => {
233
270
 
234
271
  it("includes full name from first and last", () => {
235
272
  const result = getReplyContext(
236
- { from: { id: 123, first_name: "Jane", last_name: "Smith" }, text: "hello" },
273
+ {
274
+ from: { id: 123, first_name: "Jane", last_name: "Smith" },
275
+ text: "hello",
276
+ },
237
277
  999,
238
278
  );
239
279
  expect(result).toContain("Jane Smith");
240
280
  });
281
+
282
+ it("detects video media type (ternary TRUE branch for video)", () => {
283
+ const result = getReplyContext(
284
+ { from: { id: 123 }, video: {}, message_id: 1 },
285
+ 999,
286
+ );
287
+ expect(result).toContain("[video]");
288
+ });
289
+
290
+ it("detects document media type", () => {
291
+ const result = getReplyContext(
292
+ { from: { id: 123 }, document: {}, message_id: 2 },
293
+ 999,
294
+ );
295
+ expect(result).toContain("[document]");
296
+ });
297
+
298
+ it("detects voice media type", () => {
299
+ const result = getReplyContext(
300
+ { from: { id: 123 }, voice: {}, message_id: 3 },
301
+ 999,
302
+ );
303
+ expect(result).toContain("[voice]");
304
+ });
305
+
306
+ it("detects audio media type", () => {
307
+ const result = getReplyContext(
308
+ { from: { id: 123 }, audio: {}, message_id: 4 },
309
+ 999,
310
+ );
311
+ expect(result).toContain("[audio]");
312
+ });
313
+
314
+ it("detects sticker media type", () => {
315
+ const result = getReplyContext(
316
+ { from: { id: 123 }, sticker: {}, message_id: 5 },
317
+ 999,
318
+ );
319
+ expect(result).toContain("[sticker]");
320
+ });
321
+
322
+ it("detects animation media type", () => {
323
+ const result = getReplyContext(
324
+ { from: { id: 123 }, animation: {}, message_id: 6 },
325
+ 999,
326
+ );
327
+ expect(result).toContain("[animation]");
328
+ });
241
329
  });
242
330
 
243
331
  describe("shouldHandleInGroup — edge cases", () => {
244
- const ctx = (overrides: Record<string, unknown> = {}) => ({
245
- chat: { type: overrides.type ?? "supergroup" },
246
- me: { id: overrides.botId ?? 999, username: overrides.username ?? "testbot" },
247
- message: {
248
- text: overrides.text ?? "",
249
- caption: overrides.caption ?? undefined,
250
- reply_to_message: overrides.replyFromId
251
- ? { from: { id: overrides.replyFromId } }
252
- : undefined,
253
- },
254
- }) as any;
332
+ const ctx = (overrides: Record<string, unknown> = {}) =>
333
+ ({
334
+ chat: { type: overrides.type ?? "supergroup" },
335
+ me: {
336
+ id: overrides.botId ?? 999,
337
+ username: overrides.username ?? "testbot",
338
+ },
339
+ message: {
340
+ text: overrides.text ?? "",
341
+ caption: overrides.caption ?? undefined,
342
+ reply_to_message: overrides.replyFromId
343
+ ? { from: { id: overrides.replyFromId } }
344
+ : undefined,
345
+ },
346
+ }) as any;
255
347
 
256
348
  it("returns false when no chat", () => {
257
349
  expect(shouldHandleInGroup({ chat: null, message: {} } as any)).toBe(false);
258
350
  });
259
351
 
260
352
  it("returns false when no message", () => {
261
- expect(shouldHandleInGroup({ chat: { type: "group" }, message: null } as any)).toBe(false);
353
+ expect(
354
+ shouldHandleInGroup({ chat: { type: "group" }, message: null } as any),
355
+ ).toBe(false);
262
356
  });
263
357
 
264
358
  it("detects bot mention in caption", () => {
265
- expect(shouldHandleInGroup(ctx({ caption: "look @testbot", text: "" }))).toBe(true);
359
+ expect(
360
+ shouldHandleInGroup(ctx({ caption: "look @testbot", text: "" })),
361
+ ).toBe(true);
266
362
  });
267
363
 
268
364
  it("handles 'group' type (not just supergroup)", () => {
269
- expect(shouldHandleInGroup(ctx({ type: "group", text: "@testbot hi" }))).toBe(true);
365
+ expect(
366
+ shouldHandleInGroup(ctx({ type: "group", text: "@testbot hi" })),
367
+ ).toBe(true);
270
368
  });
271
369
 
272
370
  it("returns false for group with @mention as part of longer username", () => {
273
371
  // @testbot123 should NOT match @testbot (word boundary check)
274
- expect(shouldHandleInGroup(ctx({ text: "hey @testbot_extra" }))).toBe(false);
372
+ expect(shouldHandleInGroup(ctx({ text: "hey @testbot_extra" }))).toBe(
373
+ false,
374
+ );
275
375
  });
276
376
  });
277
377
 
@@ -286,42 +386,59 @@ describe("getSenderName — edge cases", () => {
286
386
  });
287
387
  });
288
388
 
289
- const { handleTextMessage, handlePhotoMessage, handleCallbackQuery } = await import(
290
- "../frontend/telegram/handlers.js"
291
- );
292
-
293
- describe("handleTextMessage — integration via mock Context", () => {
389
+ const {
390
+ handleTextMessage,
391
+ handlePhotoMessage,
392
+ handleCallbackQuery,
393
+ handleStickerMessage,
394
+ handleVoiceMessage,
395
+ handleVideoMessage,
396
+ handleDocumentMessage,
397
+ handleAudioMessage,
398
+ handleVideoNoteMessage,
399
+ handleAnimationMessage,
400
+ } = await import("../frontend/telegram/handlers.js");
294
401
 
295
- const mockBot = {
296
- api: {
297
- sendMessage: vi.fn(async () => ({ message_id: 1 })),
298
- sendChatAction: vi.fn(async () => {}),
299
- setMessageReaction: vi.fn(async () => {}),
300
- sendMessageDraft: vi.fn(async () => {}),
301
- },
302
- } as any;
402
+ // Shared mock objects used across all handler integration tests
403
+ const mockBot = {
404
+ api: {
405
+ sendMessage: vi.fn(async () => ({ message_id: 1 })),
406
+ sendChatAction: vi.fn(async () => {}),
407
+ setMessageReaction: vi.fn(async () => {}),
408
+ sendMessageDraft: vi.fn(async () => {}),
409
+ },
410
+ } as any;
303
411
 
304
- const mockConfig = {
305
- botToken: "test",
306
- model: "claude-sonnet-4-6",
307
- maxMessageLength: 4000,
308
- workspace: "/tmp/test-workspace",
309
- } as any;
412
+ const mockConfig = {
413
+ botToken: "test",
414
+ model: "claude-sonnet-4-6",
415
+ maxMessageLength: 4000,
416
+ workspace: "/tmp/test-workspace",
417
+ } as any;
310
418
 
419
+ describe("handleTextMessage — integration via mock Context", () => {
311
420
  it("silently returns when ctx.message is missing", async () => {
312
421
  await handleTextMessage({ chat: { id: 1 } } as any, mockBot, mockConfig);
313
422
  // Should not throw
314
423
  });
315
424
 
316
425
  it("silently returns when ctx.chat is missing", async () => {
317
- await handleTextMessage({ message: { text: "hi" } } as any, mockBot, mockConfig);
426
+ await handleTextMessage(
427
+ { message: { text: "hi" } } as any,
428
+ mockBot,
429
+ mockConfig,
430
+ );
318
431
  // Should not throw
319
432
  });
320
433
 
321
434
  it("silently returns for group message without mention", async () => {
322
435
  const ctx = {
323
436
  chat: { id: 1, type: "supergroup" },
324
- message: { text: "hello everyone", message_id: 1, reply_to_message: null },
437
+ message: {
438
+ text: "hello everyone",
439
+ message_id: 1,
440
+ reply_to_message: null,
441
+ },
325
442
  me: { id: 999, username: "testbot" },
326
443
  from: { id: 1, first_name: "User" },
327
444
  } as any;
@@ -341,11 +458,2363 @@ describe("handleTextMessage — integration via mock Context", () => {
341
458
 
342
459
  it("silently returns when callback query has no data", async () => {
343
460
  const ctx = {
344
- callbackQuery: { message: { message_id: 1 } }, // no 'data' property
461
+ callbackQuery: { message: { message_id: 1 } }, // no 'data' property
345
462
  chat: { id: 1 },
346
463
  from: { id: 1, first_name: "User" },
347
464
  } as any;
348
465
  await handleCallbackQuery(ctx, mockBot, mockConfig);
349
466
  });
467
+
468
+ it("enqueues and processes a DM message via flushQueue", async () => {
469
+ executeMock.mockResolvedValue({
470
+ text: "hello back",
471
+ durationMs: 50,
472
+ inputTokens: 5,
473
+ outputTokens: 10,
474
+ cacheRead: 0,
475
+ cacheWrite: 0,
476
+ bridgeMessageCount: 0,
477
+ });
478
+
479
+ const ctx = {
480
+ chat: { id: 12345, type: "private" },
481
+ message: { text: "hello", message_id: 100, reply_to_message: null },
482
+ me: { id: 999, username: "testbot" },
483
+ from: { id: 42, first_name: "Alice" },
484
+ } as any;
485
+
486
+ await handleTextMessage(ctx, mockBot, mockConfig);
487
+
488
+ // Wait for real 500ms debounce plus buffer for async processing
489
+ await new Promise((r) => setTimeout(r, 700));
490
+
491
+ expect(executeMock).toHaveBeenCalled();
492
+ }, 3000);
493
+
494
+ it("enqueues multiple DM messages within debounce window — concatenates them", async () => {
495
+ executeMock.mockResolvedValue({
496
+ text: "concatenated response",
497
+ durationMs: 30,
498
+ inputTokens: 3,
499
+ outputTokens: 5,
500
+ cacheRead: 0,
501
+ cacheWrite: 0,
502
+ bridgeMessageCount: 0,
503
+ });
504
+
505
+ const chatId = 77777 + Math.floor(Math.random() * 1000); // unique id to avoid state sharing
506
+ const makeCtx = (text: string, msgId: number) =>
507
+ ({
508
+ chat: { id: chatId, type: "private" },
509
+ message: { text, message_id: msgId, reply_to_message: null },
510
+ me: { id: 999, username: "testbot" },
511
+ from: { id: 55, first_name: "Carol" },
512
+ }) as any;
513
+
514
+ const before = executeMock.mock.calls.length;
515
+ await handleTextMessage(makeCtx("first message", 301), mockBot, mockConfig);
516
+ await handleTextMessage(
517
+ makeCtx("second message", 302),
518
+ mockBot,
519
+ mockConfig,
520
+ );
521
+
522
+ await new Promise((r) => setTimeout(r, 700));
523
+
524
+ const newCalls = executeMock.mock.calls.slice(before).filter((c) => {
525
+ const arg = c[0] as { chatId: string };
526
+ return arg.chatId === String(chatId);
527
+ });
528
+ expect(newCalls.length).toBe(1);
529
+ const callArg = newCalls[0][0] as { prompt: string };
530
+ expect(callArg.prompt).toContain("first message");
531
+ expect(callArg.prompt).toContain("second message");
532
+ }, 3000);
533
+ });
534
+
535
+ describe("handlePhotoMessage — downloads and enqueues photo", () => {
536
+ let restoreFetch: () => void;
537
+
538
+ beforeEach(() => {
539
+ // Mock bot.api.getFile for file download
540
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/test.jpg" }));
541
+ // Create a minimal valid JPEG buffer (FF D8 header = JPEG magic bytes)
542
+ const jpegBuf = new Uint8Array(104);
543
+ jpegBuf[0] = 0xff;
544
+ jpegBuf[1] = 0xd8;
545
+ const mockFetch = vi.fn(async () => ({
546
+ ok: true,
547
+ headers: { get: (_name: string) => null },
548
+ arrayBuffer: async () => jpegBuf.buffer,
549
+ }));
550
+ restoreFetch = () => {};
551
+ vi.stubGlobal("fetch", mockFetch);
552
+
553
+ executeMock.mockResolvedValue({
554
+ text: "",
555
+ durationMs: 10,
556
+ inputTokens: 1,
557
+ outputTokens: 1,
558
+ cacheRead: 0,
559
+ cacheWrite: 0,
560
+ bridgeMessageCount: 0,
561
+ });
562
+ });
563
+
564
+ afterEach(() => {
565
+ vi.unstubAllGlobals();
566
+ });
567
+
568
+ it("downloads photo and enqueues message", async () => {
569
+ const chatId = 54321;
570
+ const ctx = {
571
+ chat: { id: chatId, type: "private" },
572
+ message: {
573
+ message_id: 500,
574
+ photo: [
575
+ {
576
+ file_id: "small_id",
577
+ file_unique_id: "small_uniq",
578
+ width: 100,
579
+ height: 100,
580
+ },
581
+ {
582
+ file_id: "large_id",
583
+ file_unique_id: "large_uniq",
584
+ width: 800,
585
+ height: 600,
586
+ },
587
+ ],
588
+ caption: "Look at this!",
589
+ reply_to_message: null,
590
+ },
591
+ me: { id: 999, username: "testbot" },
592
+ from: { id: 50, first_name: "Nina" },
593
+ } as any;
594
+
595
+ const before = executeMock.mock.calls.length;
596
+ await handlePhotoMessage(ctx, mockBot, mockConfig);
597
+ await new Promise((r) => setTimeout(r, 700));
598
+
599
+ const calls = executeMock.mock.calls.slice(before).filter((c) => {
600
+ const arg = c[0] as { chatId: string };
601
+ return arg.chatId === String(chatId);
602
+ });
603
+ expect(calls.length).toBe(1);
604
+ const arg = calls[0][0] as { prompt: string };
605
+ expect(arg.prompt).toContain("photo");
606
+ }, 3000);
607
+
608
+ it("returns error via bot API when file download fails", async () => {
609
+ vi.stubGlobal(
610
+ "fetch",
611
+ vi.fn(async () => ({ ok: false, status: 404 })),
612
+ );
613
+ const ctx = {
614
+ chat: { id: 54322, type: "private" },
615
+ message: {
616
+ message_id: 501,
617
+ photo: [
618
+ {
619
+ file_id: "bad_id",
620
+ file_unique_id: "bad_uniq",
621
+ width: 100,
622
+ height: 100,
623
+ },
624
+ ],
625
+ reply_to_message: null,
626
+ },
627
+ me: { id: 999, username: "testbot" },
628
+ from: { id: 51, first_name: "Oscar" },
629
+ } as any;
630
+
631
+ await handlePhotoMessage(ctx, mockBot, mockConfig);
632
+ // Should call sendMessage with error text
633
+ expect(mockBot.api.sendMessage).toHaveBeenCalled();
634
+ });
635
+
636
+ it("rejects oversized document files", async () => {
637
+ const ctx = {
638
+ chat: { id: 54323, type: "private" },
639
+ message: {
640
+ message_id: 502,
641
+ document: {
642
+ file_id: "big_doc",
643
+ file_unique_id: "big_uniq",
644
+ file_name: "huge.pdf",
645
+ file_size: 30 * 1024 * 1024, // 30MB > 20MB limit
646
+ mime_type: "application/pdf",
647
+ },
648
+ reply_to_message: null,
649
+ },
650
+ me: { id: 999, username: "testbot" },
651
+ from: { id: 52, first_name: "Paula" },
652
+ } as any;
653
+
654
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
655
+ expect(mockBot.api.sendMessage).toHaveBeenCalledWith(
656
+ expect.anything(),
657
+ expect.stringContaining("large"),
658
+ expect.anything(),
659
+ );
660
+ });
661
+ });
662
+
663
+ describe("handleCallbackQuery — processes button press", () => {
664
+ it("calls execute when callbackQuery has data", async () => {
665
+ executeMock.mockResolvedValue({
666
+ text: "response",
667
+ durationMs: 10,
668
+ inputTokens: 1,
669
+ outputTokens: 2,
670
+ cacheRead: 0,
671
+ cacheWrite: 0,
672
+ bridgeMessageCount: 0,
673
+ });
674
+
675
+ const ctx = {
676
+ callbackQuery: { data: "button_option_a", message: { message_id: 50 } },
677
+ chat: { id: 555, type: "private" },
678
+ from: { id: 10, first_name: "Dave" },
679
+ answerCallbackQuery: vi.fn(async () => {}),
680
+ } as any;
681
+
682
+ const before = executeMock.mock.calls.length;
683
+ await handleCallbackQuery(ctx, mockBot, mockConfig);
684
+ expect(executeMock.mock.calls.length).toBeGreaterThan(before);
685
+ const arg = executeMock.mock.calls[
686
+ executeMock.mock.calls.length - 1
687
+ ][0] as { prompt: string };
688
+ expect(arg.prompt).toContain("button_option_a");
689
+ });
690
+
691
+ it("returns early when callbackQuery has no data", async () => {
692
+ const ctx = {
693
+ callbackQuery: { message: { message_id: 51 } }, // no data
694
+ chat: { id: 556 },
695
+ from: { id: 11, first_name: "Eve" },
696
+ } as any;
697
+ const before = executeMock.mock.calls.length;
698
+ await handleCallbackQuery(ctx, mockBot, mockConfig);
699
+ expect(executeMock.mock.calls.length).toBe(before);
700
+ });
701
+ });
702
+
703
+ describe("handleVoiceMessage/handleVideoMessage/handleDocumentMessage/handleAudioMessage — early returns", () => {
704
+ it("handleVoiceMessage returns early when no voice data", async () => {
705
+ const ctx = {
706
+ chat: { id: 888, type: "private" },
707
+ message: { message_id: 10, voice: null },
708
+ me: { id: 999, username: "testbot" },
709
+ from: { id: 30, first_name: "Hank" },
710
+ } as any;
711
+ const before = executeMock.mock.calls.length;
712
+ await handleVoiceMessage(ctx, mockBot, mockConfig);
713
+ expect(executeMock.mock.calls.length).toBe(before);
714
+ });
715
+
716
+ it("handleVideoMessage returns early when no video data", async () => {
717
+ const ctx = {
718
+ chat: { id: 889, type: "private" },
719
+ message: { message_id: 11, video: null },
720
+ me: { id: 999, username: "testbot" },
721
+ from: { id: 31, first_name: "Iris" },
722
+ } as any;
723
+ const before = executeMock.mock.calls.length;
724
+ await handleVideoMessage(ctx, mockBot, mockConfig);
725
+ expect(executeMock.mock.calls.length).toBe(before);
726
+ });
727
+
728
+ it("handleDocumentMessage returns early when no document", async () => {
729
+ const ctx = {
730
+ chat: { id: 890, type: "private" },
731
+ message: { message_id: 12, document: null },
732
+ me: { id: 999, username: "testbot" },
733
+ from: { id: 32, first_name: "Jack" },
734
+ } as any;
735
+ const before = executeMock.mock.calls.length;
736
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
737
+ expect(executeMock.mock.calls.length).toBe(before);
738
+ });
739
+
740
+ it("handleAudioMessage returns early when no audio", async () => {
741
+ const ctx = {
742
+ chat: { id: 891, type: "private" },
743
+ message: { message_id: 13, audio: null },
744
+ me: { id: 999, username: "testbot" },
745
+ from: { id: 33, first_name: "Kate" },
746
+ } as any;
747
+ const before = executeMock.mock.calls.length;
748
+ await handleAudioMessage(ctx, mockBot, mockConfig);
749
+ expect(executeMock.mock.calls.length).toBe(before);
750
+ });
751
+
752
+ it("handleAnimationMessage returns early when no animation", async () => {
753
+ const ctx = {
754
+ chat: { id: 892, type: "private" },
755
+ message: { message_id: 14, animation: null },
756
+ me: { id: 999, username: "testbot" },
757
+ from: { id: 34, first_name: "Liam" },
758
+ } as any;
759
+ const before = executeMock.mock.calls.length;
760
+ await handleAnimationMessage(ctx, mockBot, mockConfig);
761
+ expect(executeMock.mock.calls.length).toBe(before);
762
+ });
763
+
764
+ it("handleVideoNoteMessage returns early when no video_note", async () => {
765
+ const ctx = {
766
+ chat: { id: 893, type: "private" },
767
+ message: { message_id: 15, video_note: null },
768
+ me: { id: 999, username: "testbot" },
769
+ from: { id: 35, first_name: "Mia" },
770
+ } as any;
771
+ const before = executeMock.mock.calls.length;
772
+ await handleVideoNoteMessage(ctx, mockBot, mockConfig);
773
+ expect(executeMock.mock.calls.length).toBe(before);
774
+ });
775
+ });
776
+
777
+ describe("rate limiting — isUserRateLimited via handleTextMessage", () => {
778
+ it("blocks message after RATE_LIMIT_MAX_MESSAGES (15) messages from same user", async () => {
779
+ const userId = 99999; // unique user ID for this test
780
+ const makeCtx = (i: number) =>
781
+ ({
782
+ chat: { id: 11111 + i, type: "private" }, // different chat each time to avoid queue conflicts
783
+ message: {
784
+ text: `message ${i}`,
785
+ message_id: 1000 + i,
786
+ reply_to_message: null,
787
+ },
788
+ me: { id: 999, username: "testbot" },
789
+ from: { id: userId, first_name: "RateLimitUser" },
790
+ }) as any;
791
+
792
+ // Send 15 messages — all should be enqueued
793
+ for (let i = 0; i < 15; i++) {
794
+ await handleTextMessage(makeCtx(i), mockBot, mockConfig);
795
+ }
796
+
797
+ // 16th message should be rate limited (return early without enqueuing)
798
+ const before = executeMock.mock.calls.length;
799
+ await handleTextMessage(makeCtx(15), mockBot, mockConfig);
800
+
801
+ // Wait to confirm no debounce fires for the 16th chat
802
+ await new Promise((r) => setTimeout(r, 50));
803
+ // The 16th message's chatId never appeared in the queue, so execute won't be called for it
804
+ const rateLimitedCalls = executeMock.mock.calls.filter((c) => {
805
+ const arg = c[0] as { chatId: string };
806
+ return arg.chatId === String(11111 + 15);
807
+ });
808
+ expect(rateLimitedCalls.length).toBe(0);
809
+ }, 3000);
810
+ });
811
+
812
+ describe("handleStickerMessage — enqueues sticker prompt", () => {
813
+ it("enqueues a sticker message", async () => {
814
+ executeMock.mockResolvedValue({
815
+ text: "",
816
+ durationMs: 10,
817
+ inputTokens: 1,
818
+ outputTokens: 1,
819
+ cacheRead: 0,
820
+ cacheWrite: 0,
821
+ bridgeMessageCount: 0,
822
+ });
823
+ const ctx = {
824
+ chat: { id: 66666, type: "private" },
825
+ message: {
826
+ message_id: 200,
827
+ sticker: {
828
+ file_id: "sticker123",
829
+ emoji: "😀",
830
+ set_name: "AnimatedEmoji",
831
+ is_animated: false,
832
+ is_video: false,
833
+ },
834
+ reply_to_message: null,
835
+ },
836
+ me: { id: 999, username: "testbot" },
837
+ from: { id: 20, first_name: "Frank" },
838
+ } as any;
839
+
840
+ const before = executeMock.mock.calls.length;
841
+ await handleStickerMessage(ctx, mockBot, mockConfig);
842
+ await new Promise((r) => setTimeout(r, 700));
843
+
844
+ const calls = executeMock.mock.calls.slice(before).filter((c) => {
845
+ const arg = c[0] as { chatId: string };
846
+ return arg.chatId === "66666";
847
+ });
848
+ expect(calls.length).toBe(1);
849
+ const arg = calls[0][0] as { prompt: string };
850
+ expect(arg.prompt).toContain("sticker");
851
+ expect(arg.prompt).toContain("sticker123");
852
+ }, 3000);
853
+
854
+ it("returns early when no sticker in message", async () => {
855
+ const ctx = {
856
+ chat: { id: 777, type: "private" },
857
+ message: { message_id: 300, sticker: null },
858
+ me: { id: 999, username: "testbot" },
859
+ from: { id: 21, first_name: "Grace" },
860
+ } as any;
861
+ const before = executeMock.mock.calls.length;
862
+ await handleStickerMessage(ctx, mockBot, mockConfig);
863
+ expect(executeMock.mock.calls.length).toBe(before);
864
+ });
865
+
866
+ it("builds sticker prompt with animated flag", async () => {
867
+ executeMock.mockResolvedValue({
868
+ text: "",
869
+ durationMs: 10,
870
+ inputTokens: 1,
871
+ outputTokens: 1,
872
+ cacheRead: 0,
873
+ cacheWrite: 0,
874
+ bridgeMessageCount: 0,
875
+ });
876
+ const chatId = 66667;
877
+ const ctx = {
878
+ chat: { id: chatId, type: "private" },
879
+ message: {
880
+ message_id: 201,
881
+ sticker: {
882
+ file_id: "anim123",
883
+ emoji: "🔥",
884
+ set_name: "",
885
+ is_animated: true,
886
+ is_video: false,
887
+ },
888
+ reply_to_message: null,
889
+ },
890
+ me: { id: 999, username: "testbot" },
891
+ from: { id: 22, first_name: "Heidi" },
892
+ } as any;
893
+
894
+ const before = executeMock.mock.calls.length;
895
+ await handleStickerMessage(ctx, mockBot, mockConfig);
896
+ await new Promise((r) => setTimeout(r, 700));
897
+
898
+ const calls = executeMock.mock.calls
899
+ .slice(before)
900
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
901
+ expect(calls.length).toBe(1);
902
+ expect((calls[0][0] as { prompt: string }).prompt).toContain("animated");
903
+ }, 3000);
904
+ });
905
+
906
+ // Helper: create a minimal non-image binary buffer (e.g. for voice/video/audio)
907
+ function makeBinaryBuffer(size = 64): ArrayBuffer {
908
+ const arr = new Uint8Array(size);
909
+ arr[0] = 0x1a;
910
+ arr[1] = 0x45; // some random non-image bytes
911
+ return arr.buffer;
912
+ }
913
+
914
+ describe("handleVoiceMessage — downloads and enqueues", () => {
915
+ beforeEach(() => {
916
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "voice/test.ogg" }));
917
+ vi.stubGlobal(
918
+ "fetch",
919
+ vi.fn(async () => ({
920
+ ok: true,
921
+ headers: { get: () => null },
922
+ arrayBuffer: async () => makeBinaryBuffer(),
923
+ })),
924
+ );
925
+ executeMock.mockResolvedValue({
926
+ text: "",
927
+ durationMs: 10,
928
+ inputTokens: 1,
929
+ outputTokens: 1,
930
+ cacheRead: 0,
931
+ cacheWrite: 0,
932
+ bridgeMessageCount: 0,
933
+ });
934
+ });
935
+
936
+ afterEach(() => {
937
+ vi.unstubAllGlobals();
938
+ });
939
+
940
+ it("downloads voice and enqueues message", async () => {
941
+ const chatId = 70001;
942
+ const ctx = {
943
+ chat: { id: chatId, type: "private" },
944
+ message: {
945
+ message_id: 600,
946
+ voice: { file_id: "voice_abc", file_unique_id: "vuq1", duration: 5 },
947
+ reply_to_message: null,
948
+ },
949
+ me: { id: 999, username: "testbot" },
950
+ from: { id: 60, first_name: "Nora" },
951
+ } as any;
952
+
953
+ const before = executeMock.mock.calls.length;
954
+ await handleVoiceMessage(ctx, mockBot, mockConfig);
955
+ await new Promise((r) => setTimeout(r, 700));
956
+
957
+ const calls = executeMock.mock.calls
958
+ .slice(before)
959
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
960
+ expect(calls.length).toBe(1);
961
+ expect((calls[0][0] as { prompt: string }).prompt).toContain("voice");
962
+ }, 3000);
963
+ });
964
+
965
+ describe("handleAudioMessage — downloads and enqueues", () => {
966
+ beforeEach(() => {
967
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "audio/test.mp3" }));
968
+ vi.stubGlobal(
969
+ "fetch",
970
+ vi.fn(async () => ({
971
+ ok: true,
972
+ headers: { get: () => null },
973
+ arrayBuffer: async () => makeBinaryBuffer(),
974
+ })),
975
+ );
976
+ executeMock.mockResolvedValue({
977
+ text: "",
978
+ durationMs: 10,
979
+ inputTokens: 1,
980
+ outputTokens: 1,
981
+ cacheRead: 0,
982
+ cacheWrite: 0,
983
+ bridgeMessageCount: 0,
984
+ });
985
+ });
986
+
987
+ afterEach(() => {
988
+ vi.unstubAllGlobals();
989
+ });
990
+
991
+ it("downloads audio with title and performer", async () => {
992
+ const chatId = 70002;
993
+ const ctx = {
994
+ chat: { id: chatId, type: "private" },
995
+ message: {
996
+ message_id: 601,
997
+ audio: {
998
+ file_id: "audio_abc",
999
+ file_unique_id: "auq1",
1000
+ duration: 180,
1001
+ title: "My Song",
1002
+ performer: "Artist",
1003
+ file_name: "song.mp3",
1004
+ file_size: 2 * 1024 * 1024,
1005
+ },
1006
+ reply_to_message: null,
1007
+ },
1008
+ me: { id: 999, username: "testbot" },
1009
+ from: { id: 61, first_name: "Oscar" },
1010
+ } as any;
1011
+
1012
+ const before = executeMock.mock.calls.length;
1013
+ await handleAudioMessage(ctx, mockBot, mockConfig);
1014
+ await new Promise((r) => setTimeout(r, 700));
1015
+
1016
+ const calls = executeMock.mock.calls
1017
+ .slice(before)
1018
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1019
+ expect(calls.length).toBe(1);
1020
+ const prompt = (calls[0][0] as { prompt: string }).prompt;
1021
+ expect(prompt).toContain("My Song");
1022
+ expect(prompt).toContain("Artist");
1023
+ }, 3000);
1024
+
1025
+ it("handles audio without title (falls back to file_name)", async () => {
1026
+ const chatId = 70003;
1027
+ const ctx = {
1028
+ chat: { id: chatId, type: "private" },
1029
+ message: {
1030
+ message_id: 602,
1031
+ audio: {
1032
+ file_id: "audio_def",
1033
+ file_unique_id: "auq2",
1034
+ duration: 60,
1035
+ file_name: "recording.mp3",
1036
+ file_size: 500_000,
1037
+ },
1038
+ reply_to_message: null,
1039
+ },
1040
+ me: { id: 999, username: "testbot" },
1041
+ from: { id: 62, first_name: "Paula" },
1042
+ } as any;
1043
+
1044
+ const before = executeMock.mock.calls.length;
1045
+ await handleAudioMessage(ctx, mockBot, mockConfig);
1046
+ await new Promise((r) => setTimeout(r, 700));
1047
+
1048
+ const calls = executeMock.mock.calls
1049
+ .slice(before)
1050
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1051
+ expect(calls.length).toBe(1);
1052
+ expect((calls[0][0] as { prompt: string }).prompt).toContain(
1053
+ "recording.mp3",
1054
+ );
1055
+ }, 3000);
1056
+ });
1057
+
1058
+ describe("handleVideoNoteMessage — downloads and enqueues", () => {
1059
+ beforeEach(() => {
1060
+ mockBot.api.getFile = vi.fn(async () => ({
1061
+ file_path: "video_note/test.mp4",
1062
+ }));
1063
+ vi.stubGlobal(
1064
+ "fetch",
1065
+ vi.fn(async () => ({
1066
+ ok: true,
1067
+ headers: { get: () => null },
1068
+ arrayBuffer: async () => makeBinaryBuffer(),
1069
+ })),
1070
+ );
1071
+ executeMock.mockResolvedValue({
1072
+ text: "",
1073
+ durationMs: 10,
1074
+ inputTokens: 1,
1075
+ outputTokens: 1,
1076
+ cacheRead: 0,
1077
+ cacheWrite: 0,
1078
+ bridgeMessageCount: 0,
1079
+ });
1080
+ });
1081
+
1082
+ afterEach(() => {
1083
+ vi.unstubAllGlobals();
1084
+ });
1085
+
1086
+ it("downloads video_note and enqueues message", async () => {
1087
+ const chatId = 70004;
1088
+ const ctx = {
1089
+ chat: { id: chatId, type: "private" },
1090
+ message: {
1091
+ message_id: 603,
1092
+ video_note: {
1093
+ file_id: "vn_abc",
1094
+ file_unique_id: "vnq1",
1095
+ duration: 15,
1096
+ file_size: 300_000,
1097
+ },
1098
+ reply_to_message: null,
1099
+ },
1100
+ me: { id: 999, username: "testbot" },
1101
+ from: { id: 63, first_name: "Quinn" },
1102
+ } as any;
1103
+
1104
+ const before = executeMock.mock.calls.length;
1105
+ await handleVideoNoteMessage(ctx, mockBot, mockConfig);
1106
+ await new Promise((r) => setTimeout(r, 700));
1107
+
1108
+ const calls = executeMock.mock.calls
1109
+ .slice(before)
1110
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1111
+ expect(calls.length).toBe(1);
1112
+ expect((calls[0][0] as { prompt: string }).prompt).toContain("round video");
1113
+ }, 3000);
1114
+ });
1115
+
1116
+ describe("handleDocumentMessage — downloads and enqueues", () => {
1117
+ beforeEach(() => {
1118
+ mockBot.api.getFile = vi.fn(async () => ({
1119
+ file_path: "documents/test.pdf",
1120
+ }));
1121
+ vi.stubGlobal(
1122
+ "fetch",
1123
+ vi.fn(async () => ({
1124
+ ok: true,
1125
+ headers: { get: () => null },
1126
+ arrayBuffer: async () => makeBinaryBuffer(),
1127
+ })),
1128
+ );
1129
+ executeMock.mockResolvedValue({
1130
+ text: "",
1131
+ durationMs: 10,
1132
+ inputTokens: 1,
1133
+ outputTokens: 1,
1134
+ cacheRead: 0,
1135
+ cacheWrite: 0,
1136
+ bridgeMessageCount: 0,
1137
+ });
1138
+ });
1139
+
1140
+ afterEach(() => {
1141
+ vi.unstubAllGlobals();
1142
+ });
1143
+
1144
+ it("downloads document and enqueues message", async () => {
1145
+ const chatId = 70005;
1146
+ const ctx = {
1147
+ chat: { id: chatId, type: "private" },
1148
+ message: {
1149
+ message_id: 604,
1150
+ document: {
1151
+ file_id: "doc_abc",
1152
+ file_unique_id: "dq1",
1153
+ file_name: "report.pdf",
1154
+ file_size: 1_000_000,
1155
+ mime_type: "application/pdf",
1156
+ },
1157
+ caption: "Please review",
1158
+ reply_to_message: null,
1159
+ },
1160
+ me: { id: 999, username: "testbot" },
1161
+ from: { id: 64, first_name: "Rachel" },
1162
+ } as any;
1163
+
1164
+ const before = executeMock.mock.calls.length;
1165
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
1166
+ await new Promise((r) => setTimeout(r, 700));
1167
+
1168
+ const calls = executeMock.mock.calls
1169
+ .slice(before)
1170
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1171
+ expect(calls.length).toBe(1);
1172
+ const prompt = (calls[0][0] as { prompt: string }).prompt;
1173
+ expect(prompt).toContain("report.pdf");
1174
+ expect(prompt).toContain("Please review");
1175
+ }, 3000);
350
1176
  });
351
1177
 
1178
+ describe("handleVideoMessage — downloads and enqueues", () => {
1179
+ beforeEach(() => {
1180
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "videos/test.mp4" }));
1181
+ vi.stubGlobal(
1182
+ "fetch",
1183
+ vi.fn(async () => ({
1184
+ ok: true,
1185
+ headers: { get: () => null },
1186
+ arrayBuffer: async () => makeBinaryBuffer(),
1187
+ })),
1188
+ );
1189
+ executeMock.mockResolvedValue({
1190
+ text: "",
1191
+ durationMs: 10,
1192
+ inputTokens: 1,
1193
+ outputTokens: 1,
1194
+ cacheRead: 0,
1195
+ cacheWrite: 0,
1196
+ bridgeMessageCount: 0,
1197
+ });
1198
+ });
1199
+
1200
+ afterEach(() => {
1201
+ vi.unstubAllGlobals();
1202
+ });
1203
+
1204
+ it("downloads video and enqueues message", async () => {
1205
+ const chatId = 70006;
1206
+ const ctx = {
1207
+ chat: { id: chatId, type: "private" },
1208
+ message: {
1209
+ message_id: 605,
1210
+ video: {
1211
+ file_id: "vid_abc",
1212
+ file_unique_id: "vq1",
1213
+ duration: 30,
1214
+ width: 1280,
1215
+ height: 720,
1216
+ file_name: "clip.mp4",
1217
+ },
1218
+ caption: "Check this out",
1219
+ reply_to_message: null,
1220
+ },
1221
+ me: { id: 999, username: "testbot" },
1222
+ from: { id: 65, first_name: "Sam" },
1223
+ } as any;
1224
+
1225
+ const before = executeMock.mock.calls.length;
1226
+ await handleVideoMessage(ctx, mockBot, mockConfig);
1227
+ await new Promise((r) => setTimeout(r, 700));
1228
+
1229
+ const calls = executeMock.mock.calls
1230
+ .slice(before)
1231
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1232
+ expect(calls.length).toBe(1);
1233
+ const prompt = (calls[0][0] as { prompt: string }).prompt;
1234
+ expect(prompt).toContain("clip.mp4");
1235
+ expect(prompt).toContain("Check this out");
1236
+ }, 3000);
1237
+ });
1238
+
1239
+ describe("handleTextMessage — group message with @mention", () => {
1240
+ it("enqueues group message when bot is mentioned", async () => {
1241
+ executeMock.mockResolvedValue({
1242
+ text: "group response",
1243
+ durationMs: 50,
1244
+ inputTokens: 5,
1245
+ outputTokens: 10,
1246
+ cacheRead: 0,
1247
+ cacheWrite: 0,
1248
+ bridgeMessageCount: 0,
1249
+ });
1250
+
1251
+ const chatId = 80001;
1252
+ const ctx = {
1253
+ chat: { id: chatId, type: "supergroup", title: "Test Group" },
1254
+ message: {
1255
+ text: "@testbot hello from group",
1256
+ message_id: 700,
1257
+ reply_to_message: null,
1258
+ },
1259
+ me: { id: 999, username: "testbot" },
1260
+ from: { id: 70, first_name: "Tom" },
1261
+ } as any;
1262
+
1263
+ const before = executeMock.mock.calls.length;
1264
+ await handleTextMessage(ctx, mockBot, mockConfig);
1265
+ await new Promise((r) => setTimeout(r, 700));
1266
+
1267
+ const calls = executeMock.mock.calls
1268
+ .slice(before)
1269
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1270
+ expect(calls.length).toBe(1);
1271
+ const arg = calls[0][0] as { isGroup: boolean; prompt: string };
1272
+ expect(arg.isGroup).toBe(true);
1273
+ expect(arg.prompt).toContain("@testbot hello from group");
1274
+ }, 3000);
1275
+ });
1276
+
1277
+ describe("handleCallbackQuery — error path", () => {
1278
+ it("logs error when processAndReply throws", async () => {
1279
+ const { logError } = await import("../util/log.js");
1280
+ executeMock.mockRejectedValueOnce(new Error("callback execute failed"));
1281
+
1282
+ const ctx = {
1283
+ callbackQuery: { data: "broken_action", message: { message_id: 800 } },
1284
+ chat: { id: 90001, type: "private" },
1285
+ from: { id: 80, first_name: "Uma" },
1286
+ answerCallbackQuery: vi.fn(async () => {}),
1287
+ } as any;
1288
+
1289
+ await handleCallbackQuery(ctx, mockBot, mockConfig);
1290
+ expect(logError).toHaveBeenCalled();
1291
+ });
1292
+ });
1293
+
1294
+ describe("flushQueue — retryable error path", () => {
1295
+ it("retries once on retryable error and sends error message on retry failure", async () => {
1296
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1297
+
1298
+ // First execute call throws (retryable), second also throws (unrecoverable)
1299
+ executeMock
1300
+ .mockRejectedValueOnce(new Error("overloaded"))
1301
+ .mockRejectedValueOnce(new Error("still overloaded"));
1302
+
1303
+ (classify as ReturnType<typeof vi.fn>)
1304
+ .mockReturnValueOnce({
1305
+ reason: "overloaded",
1306
+ message: "overloaded",
1307
+ retryable: true,
1308
+ retryAfterMs: 10,
1309
+ })
1310
+ .mockReturnValueOnce({
1311
+ reason: "overloaded",
1312
+ message: "still overloaded",
1313
+ retryable: false,
1314
+ });
1315
+
1316
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValue(
1317
+ "Service temporarily overloaded",
1318
+ );
1319
+
1320
+ const chatId = 95001;
1321
+ const ctx = {
1322
+ chat: { id: chatId, type: "private" },
1323
+ message: { text: "test retry", message_id: 900, reply_to_message: null },
1324
+ me: { id: 999, username: "testbot" },
1325
+ from: { id: 90, first_name: "Victor" },
1326
+ } as any;
1327
+
1328
+ const sendMsgCalls = mockBot.api.sendMessage.mock.calls.length;
1329
+ await handleTextMessage(ctx, mockBot, mockConfig);
1330
+ await new Promise((r) => setTimeout(r, 700));
1331
+
1332
+ // Should have sent an error message after retry failure
1333
+ expect(mockBot.api.sendMessage.mock.calls.length).toBeGreaterThan(
1334
+ sendMsgCalls,
1335
+ );
1336
+ }, 3000);
1337
+ });
1338
+
1339
+ describe("flushQueue — retryable error, successful retry (line 403 return)", () => {
1340
+ it("returns after successful retry without sending error message", async () => {
1341
+ const { classify } = await import("../core/errors.js");
1342
+
1343
+ const successResult = {
1344
+ text: "",
1345
+ durationMs: 5,
1346
+ inputTokens: 1,
1347
+ outputTokens: 1,
1348
+ cacheRead: 0,
1349
+ cacheWrite: 0,
1350
+ bridgeMessageCount: 0,
1351
+ };
1352
+ // First execute call throws (retryable), second SUCCEEDS → covers line 403 `return`
1353
+ executeMock
1354
+ .mockRejectedValueOnce(new Error("overloaded"))
1355
+ .mockResolvedValueOnce(successResult);
1356
+
1357
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1358
+ reason: "overloaded",
1359
+ message: "overloaded",
1360
+ retryable: true,
1361
+ retryAfterMs: 10,
1362
+ });
1363
+
1364
+ const sendMsgCountBefore = (
1365
+ mockBot.api.sendMessage as ReturnType<typeof vi.fn>
1366
+ ).mock.calls.length;
1367
+
1368
+ const ctx = {
1369
+ chat: { id: 95010, type: "private" },
1370
+ message: {
1371
+ text: "retry success test",
1372
+ message_id: 905,
1373
+ reply_to_message: null,
1374
+ },
1375
+ me: { id: 999, username: "testbot" },
1376
+ from: { id: 91, first_name: "Vera" },
1377
+ } as any;
1378
+
1379
+ await handleTextMessage(ctx, mockBot, mockConfig);
1380
+ await new Promise((r) => setTimeout(r, 700));
1381
+
1382
+ // Retry succeeded — no error message should have been sent
1383
+ const sendMsgCountAfter = (
1384
+ mockBot.api.sendMessage as ReturnType<typeof vi.fn>
1385
+ ).mock.calls.length;
1386
+ expect(sendMsgCountAfter).toBe(sendMsgCountBefore); // no additional sendMessage call
1387
+ }, 3000);
1388
+ });
1389
+
1390
+ describe("flushQueue — group error logs 'group' chatType (line 378 TRUE branch)", () => {
1391
+ it("logs 'group' when error occurs in group chat context", async () => {
1392
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1393
+ const { logError } = await import("../util/log.js");
1394
+
1395
+ executeMock.mockRejectedValueOnce(new Error("group exec error"));
1396
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1397
+ reason: "error",
1398
+ message: "group exec error",
1399
+ retryable: false,
1400
+ });
1401
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
1402
+ "Error occurred",
1403
+ );
1404
+ (logError as ReturnType<typeof vi.fn>).mockClear();
1405
+
1406
+ const ctx = {
1407
+ chat: { id: 95020, type: "supergroup", title: "My Group" },
1408
+ message: {
1409
+ text: "@testbot group error test",
1410
+ message_id: 910,
1411
+ reply_to_message: null,
1412
+ },
1413
+ me: { id: 999, username: "testbot" },
1414
+ from: { id: 92, first_name: "Greg" },
1415
+ } as any;
1416
+
1417
+ await handleTextMessage(ctx, mockBot, mockConfig);
1418
+ await new Promise((r) => setTimeout(r, 700));
1419
+
1420
+ // logError should include "group" in the message (not "DM")
1421
+ const logCalls = (logError as ReturnType<typeof vi.fn>).mock.calls;
1422
+ const relevantCall = logCalls.find(
1423
+ (c: unknown[]) =>
1424
+ String(c[1]).includes("group") || String(c[1]).includes("95020"),
1425
+ );
1426
+ expect(relevantCall).toBeDefined();
1427
+ expect(String(relevantCall![1])).toContain("group");
1428
+ }, 3000);
1429
+ });
1430
+
1431
+ describe("flushQueue — retry without retryAfterMs uses default 2000ms (line 388 FALSE branch)", () => {
1432
+ it("uses 2000ms default delay when retryAfterMs is undefined", async () => {
1433
+ const { classify } = await import("../core/errors.js");
1434
+
1435
+ const successResult = {
1436
+ text: "",
1437
+ durationMs: 5,
1438
+ inputTokens: 1,
1439
+ outputTokens: 1,
1440
+ cacheRead: 0,
1441
+ cacheWrite: 0,
1442
+ bridgeMessageCount: 0,
1443
+ };
1444
+ executeMock
1445
+ .mockRejectedValueOnce(new Error("overloaded"))
1446
+ .mockResolvedValueOnce(successResult);
1447
+
1448
+ // retryAfterMs is absent — covers `?? 2000` FALSE branch
1449
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1450
+ reason: "overloaded",
1451
+ message: "overloaded",
1452
+ retryable: true,
1453
+ // no retryAfterMs → falls to 2000
1454
+ });
1455
+
1456
+ // Use fake timers to avoid actually waiting 2000ms
1457
+ vi.useFakeTimers();
1458
+
1459
+ const ctx = {
1460
+ chat: { id: 95030, type: "private" },
1461
+ message: {
1462
+ text: "default delay test",
1463
+ message_id: 915,
1464
+ reply_to_message: null,
1465
+ },
1466
+ me: { id: 999, username: "testbot" },
1467
+ from: { id: 93, first_name: "Harry" },
1468
+ } as any;
1469
+
1470
+ const handlePromise = handleTextMessage(ctx, mockBot, mockConfig);
1471
+ // Advance timers past debounce (500ms) + default delay (2000ms)
1472
+ await vi.advanceTimersByTimeAsync(3000);
1473
+ await handlePromise;
1474
+
1475
+ vi.useRealTimers();
1476
+ }, 10000);
1477
+ });
1478
+
1479
+ describe("handleTextMessage — senderUsername truthy covers trackDmUser username tag (line 49 TRUE branch)", () => {
1480
+ it("tracks DM user with username when username is provided", async () => {
1481
+ const { appendDailyLog } = await import("../storage/daily-log.js");
1482
+ (appendDailyLog as ReturnType<typeof vi.fn>).mockClear();
1483
+
1484
+ executeMock.mockResolvedValueOnce({
1485
+ text: "",
1486
+ durationMs: 10,
1487
+ inputTokens: 1,
1488
+ outputTokens: 1,
1489
+ cacheRead: 0,
1490
+ cacheWrite: 0,
1491
+ bridgeMessageCount: 0,
1492
+ });
1493
+
1494
+ const ctx = {
1495
+ chat: { id: 95040, type: "private" },
1496
+ message: {
1497
+ text: "hi from @user",
1498
+ message_id: 920,
1499
+ reply_to_message: null,
1500
+ },
1501
+ me: { id: 999, username: "testbot" },
1502
+ // username provided → senderUsername is truthy → tag = " (@johndoe)"
1503
+ from: { id: 94, first_name: "John", username: "johndoe" },
1504
+ } as any;
1505
+
1506
+ await handleTextMessage(ctx, mockBot, mockConfig);
1507
+ await new Promise((r) => setTimeout(r, 700));
1508
+
1509
+ // appendDailyLog should have been called with the @username tag
1510
+ const allCalls = (appendDailyLog as ReturnType<typeof vi.fn>).mock.calls;
1511
+ const taggedCall = allCalls.find((c: unknown[]) =>
1512
+ String(c[1]).includes("@johndoe"),
1513
+ );
1514
+ expect(taggedCall).toBeDefined();
1515
+ }, 3000);
1516
+ });
1517
+
1518
+ describe("handleTextMessage — text=undefined uses empty string (line 725 FALSE branch)", () => {
1519
+ it("enqueues with empty prompt when ctx.message.text is undefined", async () => {
1520
+ executeMock.mockResolvedValueOnce({
1521
+ text: "",
1522
+ durationMs: 10,
1523
+ inputTokens: 1,
1524
+ outputTokens: 1,
1525
+ cacheRead: 0,
1526
+ cacheWrite: 0,
1527
+ bridgeMessageCount: 0,
1528
+ });
1529
+
1530
+ const ctx = {
1531
+ chat: { id: 95050, type: "private" },
1532
+ // text is undefined — covers `ctx.message.text ?? ""` FALSE branch
1533
+ message: { text: undefined, message_id: 925, reply_to_message: null },
1534
+ me: { id: 999, username: "testbot" },
1535
+ from: { id: 95, first_name: "Ina" },
1536
+ } as any;
1537
+
1538
+ const before = executeMock.mock.calls.length;
1539
+ await handleTextMessage(ctx, mockBot, mockConfig);
1540
+ await new Promise((r) => setTimeout(r, 700));
1541
+ // Should have processed the message (empty prompt is fine)
1542
+ expect(executeMock.mock.calls.length).toBeGreaterThan(before);
1543
+ }, 3000);
1544
+ });
1545
+
1546
+ describe("sendHtml — non-Error thrown in fallback path (line 445 FALSE branch)", () => {
1547
+ it("logs warning with String(err) when sendMessage throws a non-Error", async () => {
1548
+ const { logWarn } = await import("../util/log.js");
1549
+ executeMock.mockRejectedValueOnce(new Error("some error"));
1550
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1551
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1552
+ reason: "error",
1553
+ message: "some error",
1554
+ retryable: false,
1555
+ });
1556
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
1557
+ "Error text",
1558
+ );
1559
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
1560
+
1561
+ // First sendMessage throws a non-Error string, second succeeds
1562
+ let callCount = 0;
1563
+ mockBot.api.sendMessage = vi.fn(async () => {
1564
+ callCount++;
1565
+ if (callCount === 1) throw "non-error failure string"; // plain string
1566
+ return { message_id: callCount };
1567
+ });
1568
+
1569
+ const ctx = {
1570
+ chat: { id: 95060, type: "private" },
1571
+ message: {
1572
+ text: "non-error throw test",
1573
+ message_id: 930,
1574
+ reply_to_message: null,
1575
+ },
1576
+ me: { id: 999, username: "testbot" },
1577
+ from: { id: 96, first_name: "Jake" },
1578
+ } as any;
1579
+
1580
+ await handleTextMessage(ctx, mockBot, mockConfig);
1581
+ await new Promise((r) => setTimeout(r, 700));
1582
+
1583
+ // logWarn should be called with String(err) (non-Error path)
1584
+ const warnCalls = (logWarn as ReturnType<typeof vi.fn>).mock.calls;
1585
+ const htmlFailWarn = warnCalls.find((c: unknown[]) =>
1586
+ String(c[1]).includes("HTML send failed"),
1587
+ );
1588
+ expect(htmlFailWarn).toBeDefined();
1589
+ expect(String(htmlFailWarn![1])).toContain("non-error failure string");
1590
+
1591
+ mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1592
+ }, 3000);
1593
+ });
1594
+
1595
+ describe("downloadTelegramFile — content-length too large", () => {
1596
+ afterEach(() => {
1597
+ vi.unstubAllGlobals();
1598
+ });
1599
+
1600
+ it("throws when content-length header exceeds 50MB limit", async () => {
1601
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/big.jpg" }));
1602
+ vi.stubGlobal(
1603
+ "fetch",
1604
+ vi.fn(async () => ({
1605
+ ok: true,
1606
+ headers: {
1607
+ get: (name: string) =>
1608
+ name === "content-length" ? String(51 * 1024 * 1024) : null,
1609
+ },
1610
+ arrayBuffer: async () => makeBinaryBuffer(),
1611
+ })),
1612
+ );
1613
+
1614
+ const ctx = {
1615
+ chat: { id: 95002, type: "private" },
1616
+ message: {
1617
+ message_id: 901,
1618
+ photo: [
1619
+ {
1620
+ file_id: "big_photo",
1621
+ file_unique_id: "bpq1",
1622
+ width: 4000,
1623
+ height: 3000,
1624
+ },
1625
+ ],
1626
+ reply_to_message: null,
1627
+ },
1628
+ me: { id: 999, username: "testbot" },
1629
+ from: { id: 91, first_name: "Wendy" },
1630
+ } as any;
1631
+
1632
+ await handlePhotoMessage(ctx, mockBot, mockConfig);
1633
+ // Should report error via sendMessage
1634
+ expect(mockBot.api.sendMessage).toHaveBeenCalled();
1635
+ });
1636
+ });
1637
+
1638
+ describe("downloadTelegramFile — invalid image content", () => {
1639
+ afterEach(() => {
1640
+ vi.unstubAllGlobals();
1641
+ });
1642
+
1643
+ it("rejects photo with invalid magic bytes (not a real image)", async () => {
1644
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/fake.jpg" }));
1645
+ // Return a buffer with no valid image header
1646
+ const fakeData = new Uint8Array(64); // all zeros, not a valid image
1647
+ vi.stubGlobal(
1648
+ "fetch",
1649
+ vi.fn(async () => ({
1650
+ ok: true,
1651
+ headers: { get: () => null },
1652
+ arrayBuffer: async () => fakeData.buffer,
1653
+ })),
1654
+ );
1655
+
1656
+ const ctx = {
1657
+ chat: { id: 95003, type: "private" },
1658
+ message: {
1659
+ message_id: 902,
1660
+ photo: [
1661
+ {
1662
+ file_id: "fake_photo",
1663
+ file_unique_id: "fpq1",
1664
+ width: 100,
1665
+ height: 100,
1666
+ },
1667
+ ],
1668
+ reply_to_message: null,
1669
+ },
1670
+ me: { id: 999, username: "testbot" },
1671
+ from: { id: 92, first_name: "Xander" },
1672
+ } as any;
1673
+
1674
+ await handlePhotoMessage(ctx, mockBot, mockConfig);
1675
+ // Should report error via sendMessage
1676
+ expect(mockBot.api.sendMessage).toHaveBeenCalled();
1677
+ });
1678
+ });
1679
+
1680
+ describe("handleAnimationMessage — downloads and enqueues", () => {
1681
+ beforeEach(() => {
1682
+ mockBot.api.getFile = vi.fn(async () => ({
1683
+ file_path: "animations/test.mp4",
1684
+ }));
1685
+ vi.stubGlobal(
1686
+ "fetch",
1687
+ vi.fn(async () => ({
1688
+ ok: true,
1689
+ headers: { get: () => null },
1690
+ arrayBuffer: async () => makeBinaryBuffer(),
1691
+ })),
1692
+ );
1693
+ executeMock.mockResolvedValue({
1694
+ text: "",
1695
+ durationMs: 10,
1696
+ inputTokens: 1,
1697
+ outputTokens: 1,
1698
+ cacheRead: 0,
1699
+ cacheWrite: 0,
1700
+ bridgeMessageCount: 0,
1701
+ });
1702
+ });
1703
+
1704
+ afterEach(() => {
1705
+ vi.unstubAllGlobals();
1706
+ });
1707
+
1708
+ it("downloads animation and enqueues message", async () => {
1709
+ const chatId = 70007;
1710
+ const ctx = {
1711
+ chat: { id: chatId, type: "private" },
1712
+ message: {
1713
+ message_id: 606,
1714
+ animation: {
1715
+ file_id: "anim_abc",
1716
+ file_unique_id: "aq1",
1717
+ duration: 3,
1718
+ file_name: "funny.mp4",
1719
+ },
1720
+ caption: "lol",
1721
+ reply_to_message: null,
1722
+ },
1723
+ me: { id: 999, username: "testbot" },
1724
+ from: { id: 66, first_name: "Tara" },
1725
+ } as any;
1726
+
1727
+ const before = executeMock.mock.calls.length;
1728
+ await handleAnimationMessage(ctx, mockBot, mockConfig);
1729
+ await new Promise((r) => setTimeout(r, 700));
1730
+
1731
+ const calls = executeMock.mock.calls
1732
+ .slice(before)
1733
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
1734
+ expect(calls.length).toBe(1);
1735
+ const prompt = (calls[0][0] as { prompt: string }).prompt;
1736
+ expect(prompt).toContain("funny.mp4");
1737
+ expect(prompt).toContain("lol");
1738
+ }, 3000);
1739
+ });
1740
+
1741
+ describe("processAndReply — onToolUse callback triggers appendDailyLogResponse", () => {
1742
+ it("calls appendDailyLogResponse when execute invokes onToolUse with send tool", async () => {
1743
+ const { appendDailyLogResponse } = await import("../storage/daily-log.js");
1744
+
1745
+ executeMock.mockImplementationOnce(
1746
+ async (params: Record<string, unknown>) => {
1747
+ // Simulate execute calling onToolUse with a "send" tool
1748
+ const onToolUse = params.onToolUse as (
1749
+ toolName: string,
1750
+ input: Record<string, unknown>,
1751
+ ) => void;
1752
+ onToolUse?.("send", { type: "text", text: "Hello from Claude!" });
1753
+ return {
1754
+ text: "",
1755
+ durationMs: 5,
1756
+ inputTokens: 1,
1757
+ outputTokens: 2,
1758
+ cacheRead: 0,
1759
+ cacheWrite: 0,
1760
+ bridgeMessageCount: 1,
1761
+ };
1762
+ },
1763
+ );
1764
+
1765
+ const ctx = {
1766
+ chat: { id: 96001, type: "private" },
1767
+ message: { text: "hi", message_id: 950, reply_to_message: null },
1768
+ me: { id: 999, username: "testbot" },
1769
+ from: { id: 93, first_name: "Yuki" },
1770
+ } as any;
1771
+
1772
+ await handleTextMessage(ctx, mockBot, mockConfig);
1773
+ await new Promise((r) => setTimeout(r, 700));
1774
+
1775
+ expect(appendDailyLogResponse).toHaveBeenCalledWith(
1776
+ "Talon",
1777
+ "Hello from Claude!",
1778
+ expect.anything(),
1779
+ );
1780
+ }, 3000);
1781
+ });
1782
+
1783
+ describe("createStreamCallbacks — onTextBlock delivers message via sendHtml", () => {
1784
+ it("calls sendMessage when execute invokes onTextBlock", async () => {
1785
+ const sendMsgCount = () =>
1786
+ (mockBot.api.sendMessage as ReturnType<typeof vi.fn>).mock.calls.length;
1787
+
1788
+ executeMock.mockImplementationOnce(
1789
+ async (params: Record<string, unknown>) => {
1790
+ const onTextBlock = params.onTextBlock as (
1791
+ text: string,
1792
+ ) => Promise<void>;
1793
+ await onTextBlock?.("**Bold response**");
1794
+ return {
1795
+ text: "",
1796
+ durationMs: 5,
1797
+ inputTokens: 1,
1798
+ outputTokens: 2,
1799
+ cacheRead: 0,
1800
+ cacheWrite: 0,
1801
+ bridgeMessageCount: 1,
1802
+ };
1803
+ },
1804
+ );
1805
+
1806
+ const before = sendMsgCount();
1807
+ const ctx = {
1808
+ chat: { id: 96002, type: "private" },
1809
+ message: {
1810
+ text: "test onTextBlock",
1811
+ message_id: 951,
1812
+ reply_to_message: null,
1813
+ },
1814
+ me: { id: 999, username: "testbot" },
1815
+ from: { id: 93, first_name: "Yuki" },
1816
+ } as any;
1817
+
1818
+ await handleTextMessage(ctx, mockBot, mockConfig);
1819
+ await new Promise((r) => setTimeout(r, 700));
1820
+
1821
+ expect(sendMsgCount()).toBeGreaterThan(before);
1822
+ }, 3000);
1823
+ });
1824
+
1825
+ describe("sendHtml — falls back to plain text on HTML send failure", () => {
1826
+ it("sends plain text when HTML mode fails", async () => {
1827
+ executeMock.mockResolvedValue({
1828
+ text: "",
1829
+ durationMs: 10,
1830
+ inputTokens: 1,
1831
+ outputTokens: 1,
1832
+ cacheRead: 0,
1833
+ cacheWrite: 0,
1834
+ bridgeMessageCount: 0,
1835
+ });
1836
+
1837
+ // Make the first sendMessage (HTML) throw, then succeed on second call (plain text)
1838
+ let callCount = 0;
1839
+ mockBot.api.sendMessage = vi.fn(async () => {
1840
+ callCount++;
1841
+ if (callCount === 1) throw new Error("Bad Request: can't parse entities");
1842
+ return { message_id: callCount };
1843
+ });
1844
+
1845
+ // Trigger sendHtml via the error handler path (classify returns non-retryable error)
1846
+ const { classify, friendlyMessage } = await import("../core/errors.js");
1847
+ executeMock.mockRejectedValueOnce(new Error("some error"));
1848
+ (classify as ReturnType<typeof vi.fn>).mockReturnValueOnce({
1849
+ reason: "error",
1850
+ message: "some error",
1851
+ retryable: false,
1852
+ });
1853
+ (friendlyMessage as ReturnType<typeof vi.fn>).mockReturnValueOnce(
1854
+ "<b>Bold error</b>",
1855
+ );
1856
+
1857
+ const ctx = {
1858
+ chat: { id: 97001, type: "private" },
1859
+ message: {
1860
+ text: "test html fallback",
1861
+ message_id: 960,
1862
+ reply_to_message: null,
1863
+ },
1864
+ me: { id: 999, username: "testbot" },
1865
+ from: { id: 94, first_name: "Zoe" },
1866
+ } as any;
1867
+
1868
+ await handleTextMessage(ctx, mockBot, mockConfig);
1869
+ await new Promise((r) => setTimeout(r, 700));
1870
+
1871
+ // Second call should be plain text (HTML stripped)
1872
+ expect(mockBot.api.sendMessage).toHaveBeenCalledTimes(2);
1873
+ const secondCallArg = (mockBot.api.sendMessage as ReturnType<typeof vi.fn>)
1874
+ .mock.calls[1][1];
1875
+ expect(secondCallArg).toContain("Bold error");
1876
+ expect(secondCallArg).not.toContain("<b>");
1877
+
1878
+ // Restore sendMessage mock for other tests
1879
+ mockBot.api.sendMessage = vi.fn(async () => ({ message_id: 1 }));
1880
+ }, 3000);
1881
+ });
1882
+
1883
+ describe("createStreamCallbacks — onStreamDelta streaming path", () => {
1884
+ it("calls sendMessageDraft when state.started is true and delta is large enough", async () => {
1885
+ // Reset sendMessageDraft mock and use a fresh spy
1886
+ mockBot.api.sendMessageDraft = vi.fn(async () => {});
1887
+
1888
+ executeMock.mockImplementationOnce(
1889
+ async (params: Record<string, unknown>) => {
1890
+ const onStreamDelta = params.onStreamDelta as (
1891
+ acc: string,
1892
+ phase?: string,
1893
+ ) => Promise<void>;
1894
+ // Wait 1100ms so the internal 1000ms stream.started timer fires first
1895
+ await new Promise((r) => setTimeout(r, 1100));
1896
+ // Now state.started = true; accumulated delta > 40 chars > lastSentLength=0
1897
+ if (onStreamDelta) await onStreamDelta("a".repeat(50), "text");
1898
+ return {
1899
+ text: "",
1900
+ durationMs: 10,
1901
+ inputTokens: 1,
1902
+ outputTokens: 1,
1903
+ cacheRead: 0,
1904
+ cacheWrite: 0,
1905
+ bridgeMessageCount: 0,
1906
+ };
1907
+ },
1908
+ );
1909
+
1910
+ const ctx = {
1911
+ chat: { id: 98001, type: "private" },
1912
+ message: {
1913
+ text: "streaming test",
1914
+ message_id: 970,
1915
+ reply_to_message: null,
1916
+ },
1917
+ me: { id: 999, username: "testbot" },
1918
+ from: { id: 95, first_name: "Stream" },
1919
+ } as any;
1920
+
1921
+ await handleTextMessage(ctx, mockBot, mockConfig);
1922
+ // Wait for debounce (500ms) + executeMock sleep (1100ms) + buffer
1923
+ await new Promise((r) => setTimeout(r, 2000));
1924
+
1925
+ expect(mockBot.api.sendMessageDraft).toHaveBeenCalled();
1926
+ }, 5000);
1927
+
1928
+ it("calls sendMessageDraft with truncated text when accumulated > 3900 chars", async () => {
1929
+ mockBot.api.sendMessageDraft = vi.fn(async () => {});
1930
+
1931
+ executeMock.mockImplementationOnce(
1932
+ async (params: Record<string, unknown>) => {
1933
+ const onStreamDelta = params.onStreamDelta as (
1934
+ acc: string,
1935
+ phase?: string,
1936
+ ) => Promise<void>;
1937
+ await new Promise((r) => setTimeout(r, 1100));
1938
+ // accumulated.length > 3900 → triggers truncation + ellipsis
1939
+ if (onStreamDelta) await onStreamDelta("b".repeat(4000), "text");
1940
+ return {
1941
+ text: "",
1942
+ durationMs: 10,
1943
+ inputTokens: 1,
1944
+ outputTokens: 1,
1945
+ cacheRead: 0,
1946
+ cacheWrite: 0,
1947
+ bridgeMessageCount: 0,
1948
+ };
1949
+ },
1950
+ );
1951
+
1952
+ const ctx = {
1953
+ chat: { id: 98002, type: "private" },
1954
+ message: {
1955
+ text: "long streaming test",
1956
+ message_id: 971,
1957
+ reply_to_message: null,
1958
+ },
1959
+ me: { id: 999, username: "testbot" },
1960
+ from: { id: 95, first_name: "Stream" },
1961
+ } as any;
1962
+
1963
+ await handleTextMessage(ctx, mockBot, mockConfig);
1964
+ await new Promise((r) => setTimeout(r, 2000));
1965
+
1966
+ const calls = (mockBot.api.sendMessageDraft as ReturnType<typeof vi.fn>)
1967
+ .mock.calls;
1968
+ if (calls.length > 0) {
1969
+ // Third arg is the display text — should be truncated with ellipsis
1970
+ const displayText = calls[calls.length - 1][2] as string;
1971
+ expect(displayText.length).toBeLessThanOrEqual(3901);
1972
+ }
1973
+ }, 5000);
1974
+
1975
+ it("disables streaming when sendMessageDraft throws on first attempt", async () => {
1976
+ // This test relies on draftsSupported resetting to null — but it's module-level state.
1977
+ // We test that even if it was set, the sendMessageDraft failure path (catch block) works.
1978
+ mockBot.api.sendMessageDraft = vi.fn(async () => {
1979
+ throw new Error("not supported");
1980
+ });
1981
+
1982
+ executeMock.mockImplementationOnce(
1983
+ async (params: Record<string, unknown>) => {
1984
+ const onStreamDelta = params.onStreamDelta as (
1985
+ acc: string,
1986
+ phase?: string,
1987
+ ) => Promise<void>;
1988
+ await new Promise((r) => setTimeout(r, 1100));
1989
+ if (onStreamDelta) await onStreamDelta("c".repeat(50), "text");
1990
+ return {
1991
+ text: "",
1992
+ durationMs: 10,
1993
+ inputTokens: 1,
1994
+ outputTokens: 1,
1995
+ cacheRead: 0,
1996
+ cacheWrite: 0,
1997
+ bridgeMessageCount: 0,
1998
+ };
1999
+ },
2000
+ );
2001
+
2002
+ const { logWarn } = await import("../util/log.js");
2003
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
2004
+
2005
+ const ctx = {
2006
+ chat: { id: 98003, type: "private" },
2007
+ message: {
2008
+ text: "streaming fail test",
2009
+ message_id: 972,
2010
+ reply_to_message: null,
2011
+ },
2012
+ me: { id: 999, username: "testbot" },
2013
+ from: { id: 95, first_name: "Stream" },
2014
+ } as any;
2015
+
2016
+ await handleTextMessage(ctx, mockBot, mockConfig);
2017
+ await new Promise((r) => setTimeout(r, 2000));
2018
+
2019
+ // Either draftsSupported was already true (from prior test) and no warn is logged,
2020
+ // OR it was null and now the warn is logged — both are valid code paths.
2021
+ // Just verify no crash occurred.
2022
+ expect(mockBot.api.sendMessageDraft).toHaveBeenCalled();
2023
+
2024
+ // Restore sendMessageDraft
2025
+ mockBot.api.sendMessageDraft = vi.fn(async () => {});
2026
+ }, 5000);
2027
+
2028
+ it("returns early from onStreamDelta when state.started is false (line 495 !state.started TRUE branch)", async () => {
2029
+ mockBot.api.sendMessageDraft = vi.fn(async () => {});
2030
+
2031
+ executeMock.mockImplementationOnce(
2032
+ async (params: Record<string, unknown>) => {
2033
+ const onStreamDelta = params.onStreamDelta as (
2034
+ acc: string,
2035
+ phase?: string,
2036
+ ) => Promise<void>;
2037
+ // Call IMMEDIATELY — state.started is still false (timer hasn't fired)
2038
+ if (onStreamDelta) await onStreamDelta("a".repeat(50), "text");
2039
+ return {
2040
+ text: "",
2041
+ durationMs: 10,
2042
+ inputTokens: 1,
2043
+ outputTokens: 1,
2044
+ cacheRead: 0,
2045
+ cacheWrite: 0,
2046
+ bridgeMessageCount: 0,
2047
+ };
2048
+ },
2049
+ );
2050
+
2051
+ const ctx = {
2052
+ chat: { id: 98004, type: "private" },
2053
+ message: {
2054
+ text: "early delta test",
2055
+ message_id: 973,
2056
+ reply_to_message: null,
2057
+ },
2058
+ me: { id: 999, username: "testbot" },
2059
+ from: { id: 95, first_name: "Stream" },
2060
+ } as any;
2061
+
2062
+ await handleTextMessage(ctx, mockBot, mockConfig);
2063
+ await new Promise((r) => setTimeout(r, 700));
2064
+
2065
+ // sendMessageDraft should NOT have been called since state.started was false
2066
+ expect(mockBot.api.sendMessageDraft).not.toHaveBeenCalled();
2067
+ }, 3000);
2068
+
2069
+ it("returns early from onStreamDelta when delta < 40 chars (line 496 TRUE branch)", async () => {
2070
+ mockBot.api.sendMessageDraft = vi.fn(async () => {});
2071
+
2072
+ executeMock.mockImplementationOnce(
2073
+ async (params: Record<string, unknown>) => {
2074
+ const onStreamDelta = params.onStreamDelta as (
2075
+ acc: string,
2076
+ phase?: string,
2077
+ ) => Promise<void>;
2078
+ await new Promise((r) => setTimeout(r, 1100)); // wait for started
2079
+ // Only 20 chars since lastSentLength=0 → 20 < 40 → early return
2080
+ if (onStreamDelta) await onStreamDelta("a".repeat(20), "text");
2081
+ return {
2082
+ text: "",
2083
+ durationMs: 10,
2084
+ inputTokens: 1,
2085
+ outputTokens: 1,
2086
+ cacheRead: 0,
2087
+ cacheWrite: 0,
2088
+ bridgeMessageCount: 0,
2089
+ };
2090
+ },
2091
+ );
2092
+
2093
+ const ctx = {
2094
+ chat: { id: 98005, type: "private" },
2095
+ message: {
2096
+ text: "small delta test",
2097
+ message_id: 974,
2098
+ reply_to_message: null,
2099
+ },
2100
+ me: { id: 999, username: "testbot" },
2101
+ from: { id: 95, first_name: "Stream" },
2102
+ } as any;
2103
+
2104
+ await handleTextMessage(ctx, mockBot, mockConfig);
2105
+ await new Promise((r) => setTimeout(r, 2000));
2106
+
2107
+ // sendMessageDraft should NOT have been called — delta < 40
2108
+ expect(mockBot.api.sendMessageDraft).not.toHaveBeenCalled();
2109
+ }, 5000);
2110
+ });
2111
+
2112
+ describe("downloadReplyPhoto — success and error paths", () => {
2113
+ afterEach(() => {
2114
+ vi.unstubAllGlobals();
2115
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "photos/test.jpg" }));
2116
+ });
2117
+
2118
+ it("downloads reply photo and adds path to prompt (L134 FALSE branch)", async () => {
2119
+ mockBot.api.getFile = vi.fn(async () => ({
2120
+ file_path: "photos/reply.jpg",
2121
+ }));
2122
+ const jpegBuf = new Uint8Array(64);
2123
+ jpegBuf[0] = 0xff;
2124
+ jpegBuf[1] = 0xd8;
2125
+ vi.stubGlobal(
2126
+ "fetch",
2127
+ vi.fn(async () => ({
2128
+ ok: true,
2129
+ headers: { get: () => null },
2130
+ arrayBuffer: async () => jpegBuf.buffer,
2131
+ })),
2132
+ );
2133
+ executeMock.mockResolvedValueOnce({
2134
+ text: "",
2135
+ durationMs: 10,
2136
+ inputTokens: 1,
2137
+ outputTokens: 1,
2138
+ cacheRead: 0,
2139
+ cacheWrite: 0,
2140
+ bridgeMessageCount: 0,
2141
+ });
2142
+
2143
+ const chatId = 99001;
2144
+ const ctx = {
2145
+ chat: { id: chatId, type: "private" },
2146
+ message: {
2147
+ text: "look at this",
2148
+ message_id: 1001,
2149
+ reply_to_message: {
2150
+ from: { id: 200, first_name: "Alice" },
2151
+ photo: [
2152
+ {
2153
+ file_id: "reply_photo_id",
2154
+ file_unique_id: "rpq1",
2155
+ width: 400,
2156
+ height: 300,
2157
+ },
2158
+ ],
2159
+ },
2160
+ },
2161
+ me: { id: 999, username: "testbot" },
2162
+ from: { id: 97, first_name: "Bob" },
2163
+ } as any;
2164
+
2165
+ const before = executeMock.mock.calls.length;
2166
+ await handleTextMessage(ctx, mockBot, mockConfig);
2167
+ await new Promise((r) => setTimeout(r, 700));
2168
+
2169
+ const calls = executeMock.mock.calls
2170
+ .slice(before)
2171
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2172
+ expect(calls.length).toBe(1);
2173
+ expect((calls[0][0] as { prompt: string }).prompt).toContain(
2174
+ "Replied-to message contains a photo",
2175
+ );
2176
+ }, 3000);
2177
+
2178
+ it("logs warning on Error in downloadReplyPhoto catch (L146 TRUE branch)", async () => {
2179
+ const { logWarn } = await import("../util/log.js");
2180
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
2181
+ mockBot.api.getFile = vi.fn(async () => {
2182
+ throw new Error("getFile failed");
2183
+ });
2184
+ executeMock.mockResolvedValueOnce({
2185
+ text: "",
2186
+ durationMs: 10,
2187
+ inputTokens: 1,
2188
+ outputTokens: 1,
2189
+ cacheRead: 0,
2190
+ cacheWrite: 0,
2191
+ bridgeMessageCount: 0,
2192
+ });
2193
+
2194
+ const chatId = 99002;
2195
+ const ctx = {
2196
+ chat: { id: chatId, type: "private" },
2197
+ message: {
2198
+ text: "see this",
2199
+ message_id: 1002,
2200
+ reply_to_message: {
2201
+ from: { id: 200 },
2202
+ photo: [
2203
+ {
2204
+ file_id: "bad_photo",
2205
+ file_unique_id: "bpq2",
2206
+ width: 100,
2207
+ height: 100,
2208
+ },
2209
+ ],
2210
+ },
2211
+ },
2212
+ me: { id: 999, username: "testbot" },
2213
+ from: { id: 97, first_name: "Bob" },
2214
+ } as any;
2215
+
2216
+ await handleTextMessage(ctx, mockBot, mockConfig);
2217
+ await new Promise((r) => setTimeout(r, 700));
2218
+
2219
+ const photoWarn = (logWarn as ReturnType<typeof vi.fn>).mock.calls.find(
2220
+ (c: unknown[]) => String(c[1]).includes("reply photo"),
2221
+ );
2222
+ expect(photoWarn).toBeDefined();
2223
+ expect(String(photoWarn![1])).toContain("getFile failed");
2224
+ }, 3000);
2225
+
2226
+ it("covers non-Error in downloadReplyPhoto catch (L146 FALSE branch)", async () => {
2227
+ const { logWarn } = await import("../util/log.js");
2228
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
2229
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
2230
+ mockBot.api.getFile = vi.fn(async () => {
2231
+ throw "non-error string";
2232
+ });
2233
+ executeMock.mockResolvedValueOnce({
2234
+ text: "",
2235
+ durationMs: 10,
2236
+ inputTokens: 1,
2237
+ outputTokens: 1,
2238
+ cacheRead: 0,
2239
+ cacheWrite: 0,
2240
+ bridgeMessageCount: 0,
2241
+ });
2242
+
2243
+ const chatId = 99003;
2244
+ const ctx = {
2245
+ chat: { id: chatId, type: "private" },
2246
+ message: {
2247
+ text: "see this too",
2248
+ message_id: 1003,
2249
+ reply_to_message: {
2250
+ from: { id: 200 },
2251
+ photo: [
2252
+ {
2253
+ file_id: "bad_photo2",
2254
+ file_unique_id: "bpq3",
2255
+ width: 100,
2256
+ height: 100,
2257
+ },
2258
+ ],
2259
+ },
2260
+ },
2261
+ me: { id: 999, username: "testbot" },
2262
+ from: { id: 97, first_name: "Bob" },
2263
+ } as any;
2264
+
2265
+ await handleTextMessage(ctx, mockBot, mockConfig);
2266
+ await new Promise((r) => setTimeout(r, 700));
2267
+
2268
+ const photoWarn = (logWarn as ReturnType<typeof vi.fn>).mock.calls.find(
2269
+ (c: unknown[]) =>
2270
+ String(c[1]).includes("reply photo") &&
2271
+ String(c[1]).includes("non-error string"),
2272
+ );
2273
+ expect(photoWarn).toBeDefined();
2274
+ }, 3000);
2275
+ });
2276
+
2277
+ describe("downloadTelegramFile — PNG/GIF/WebP image magic bytes", () => {
2278
+ beforeEach(() => {
2279
+ executeMock.mockResolvedValue({
2280
+ text: "",
2281
+ durationMs: 10,
2282
+ inputTokens: 1,
2283
+ outputTokens: 1,
2284
+ cacheRead: 0,
2285
+ cacheWrite: 0,
2286
+ bridgeMessageCount: 0,
2287
+ });
2288
+ });
2289
+
2290
+ afterEach(() => {
2291
+ vi.unstubAllGlobals();
2292
+ });
2293
+
2294
+ it("accepts PNG file with valid magic bytes (L208 TRUE branch)", async () => {
2295
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "docs/image.png" }));
2296
+ const pngBuf = new Uint8Array(64);
2297
+ pngBuf[0] = 0x89;
2298
+ pngBuf[1] = 0x50;
2299
+ pngBuf[2] = 0x4e;
2300
+ pngBuf[3] = 0x47;
2301
+ vi.stubGlobal(
2302
+ "fetch",
2303
+ vi.fn(async () => ({
2304
+ ok: true,
2305
+ headers: { get: () => null },
2306
+ arrayBuffer: async () => pngBuf.buffer,
2307
+ })),
2308
+ );
2309
+
2310
+ const chatId = 99100;
2311
+ const ctx = {
2312
+ chat: { id: chatId, type: "private" },
2313
+ message: {
2314
+ message_id: 1100,
2315
+ document: {
2316
+ file_id: "png_doc",
2317
+ file_unique_id: "pngq1",
2318
+ file_name: "screenshot.png",
2319
+ file_size: 1000,
2320
+ mime_type: "image/png",
2321
+ },
2322
+ reply_to_message: null,
2323
+ },
2324
+ me: { id: 999, username: "testbot" },
2325
+ from: { id: 98, first_name: "Alice" },
2326
+ } as any;
2327
+
2328
+ const before = executeMock.mock.calls.length;
2329
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
2330
+ await new Promise((r) => setTimeout(r, 700));
2331
+
2332
+ const calls = executeMock.mock.calls
2333
+ .slice(before)
2334
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2335
+ expect(calls.length).toBe(1);
2336
+ }, 3000);
2337
+
2338
+ it("accepts GIF file with valid magic bytes (L209 TRUE branch)", async () => {
2339
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "docs/image.gif" }));
2340
+ const gifBuf = new Uint8Array(64);
2341
+ gifBuf[0] = 0x47;
2342
+ gifBuf[1] = 0x49;
2343
+ gifBuf[2] = 0x46;
2344
+ vi.stubGlobal(
2345
+ "fetch",
2346
+ vi.fn(async () => ({
2347
+ ok: true,
2348
+ headers: { get: () => null },
2349
+ arrayBuffer: async () => gifBuf.buffer,
2350
+ })),
2351
+ );
2352
+
2353
+ const chatId = 99101;
2354
+ const ctx = {
2355
+ chat: { id: chatId, type: "private" },
2356
+ message: {
2357
+ message_id: 1101,
2358
+ document: {
2359
+ file_id: "gif_doc",
2360
+ file_unique_id: "gifq1",
2361
+ file_name: "animation.gif",
2362
+ file_size: 2000,
2363
+ mime_type: "image/gif",
2364
+ },
2365
+ reply_to_message: null,
2366
+ },
2367
+ me: { id: 999, username: "testbot" },
2368
+ from: { id: 98, first_name: "Bob" },
2369
+ } as any;
2370
+
2371
+ const before = executeMock.mock.calls.length;
2372
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
2373
+ await new Promise((r) => setTimeout(r, 700));
2374
+
2375
+ const calls = executeMock.mock.calls
2376
+ .slice(before)
2377
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2378
+ expect(calls.length).toBe(1);
2379
+ }, 3000);
2380
+
2381
+ it("accepts WebP file with valid magic bytes (L210-211 TRUE branch)", async () => {
2382
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "docs/image.webp" }));
2383
+ const webpBuf = new Uint8Array(64);
2384
+ webpBuf[0] = 0x52;
2385
+ webpBuf[1] = 0x49;
2386
+ webpBuf[2] = 0x46;
2387
+ webpBuf[3] = 0x46;
2388
+ webpBuf[8] = 0x57;
2389
+ webpBuf[9] = 0x45;
2390
+ webpBuf[10] = 0x42;
2391
+ webpBuf[11] = 0x50;
2392
+ vi.stubGlobal(
2393
+ "fetch",
2394
+ vi.fn(async () => ({
2395
+ ok: true,
2396
+ headers: { get: () => null },
2397
+ arrayBuffer: async () => webpBuf.buffer,
2398
+ })),
2399
+ );
2400
+
2401
+ const chatId = 99102;
2402
+ const ctx = {
2403
+ chat: { id: chatId, type: "private" },
2404
+ message: {
2405
+ message_id: 1102,
2406
+ document: {
2407
+ file_id: "webp_doc",
2408
+ file_unique_id: "webpq1",
2409
+ file_name: "image.webp",
2410
+ file_size: 3000,
2411
+ mime_type: "image/webp",
2412
+ },
2413
+ reply_to_message: null,
2414
+ },
2415
+ me: { id: 999, username: "testbot" },
2416
+ from: { id: 98, first_name: "Carol" },
2417
+ } as any;
2418
+
2419
+ const before = executeMock.mock.calls.length;
2420
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
2421
+ await new Promise((r) => setTimeout(r, 700));
2422
+
2423
+ const calls = executeMock.mock.calls
2424
+ .slice(before)
2425
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2426
+ expect(calls.length).toBe(1);
2427
+ }, 3000);
2428
+
2429
+ it("throws on empty downloaded buffer (L198 TRUE branch)", async () => {
2430
+ mockBot.api.getFile = vi.fn(async () => ({ file_path: "docs/empty.pdf" }));
2431
+ vi.stubGlobal(
2432
+ "fetch",
2433
+ vi.fn(async () => ({
2434
+ ok: true,
2435
+ headers: { get: () => null },
2436
+ arrayBuffer: async () => new ArrayBuffer(0),
2437
+ })),
2438
+ );
2439
+
2440
+ const chatId = 99103;
2441
+ const ctx = {
2442
+ chat: { id: chatId, type: "private" },
2443
+ message: {
2444
+ message_id: 1103,
2445
+ document: {
2446
+ file_id: "empty_doc",
2447
+ file_unique_id: "emq1",
2448
+ file_name: "empty.pdf",
2449
+ file_size: 0,
2450
+ },
2451
+ reply_to_message: null,
2452
+ },
2453
+ me: { id: 999, username: "testbot" },
2454
+ from: { id: 98, first_name: "Dan" },
2455
+ } as any;
2456
+
2457
+ await handleDocumentMessage(ctx, mockBot, mockConfig);
2458
+ // Error path — sendMessage called with error text
2459
+ expect(mockBot.api.sendMessage).toHaveBeenCalled();
2460
+ }, 3000);
2461
+ });
2462
+
2463
+ describe("enqueueMessage — queue overflow drops 21st message (L311 TRUE branch)", () => {
2464
+ it("drops message when queue reaches MAX_QUEUED_PER_CHAT (20)", async () => {
2465
+ executeMock.mockResolvedValue({
2466
+ text: "",
2467
+ durationMs: 10,
2468
+ inputTokens: 1,
2469
+ outputTokens: 1,
2470
+ cacheRead: 0,
2471
+ cacheWrite: 0,
2472
+ bridgeMessageCount: 0,
2473
+ });
2474
+
2475
+ const chatId = 99200;
2476
+ // Use unique userId per message to avoid rate-limit (15/min per user)
2477
+ const makeCtx = (i: number) =>
2478
+ ({
2479
+ chat: { id: chatId, type: "private" },
2480
+ message: {
2481
+ text: `flood ${i}`,
2482
+ message_id: 2000 + i,
2483
+ reply_to_message: null,
2484
+ },
2485
+ me: { id: 999, username: "testbot" },
2486
+ from: { id: 9900000 + i, first_name: "Flood" }, // unique userId per message
2487
+ }) as any;
2488
+
2489
+ // 21 rapid messages: 1st creates entry, 2nd-20th fill it, 21st is dropped
2490
+ for (let i = 0; i < 21; i++) {
2491
+ await handleTextMessage(makeCtx(i), mockBot, mockConfig);
2492
+ }
2493
+ await new Promise((r) => setTimeout(r, 700));
2494
+
2495
+ const calls = executeMock.mock.calls.filter(
2496
+ (c) => (c[0] as { chatId: string }).chatId === String(chatId),
2497
+ );
2498
+ expect(calls.length).toBe(1);
2499
+ const prompt = (calls[0][0] as { prompt: string }).prompt;
2500
+ // 20 messages in prompt, 21st was dropped
2501
+ const count = (prompt.match(/flood \d+/g) || []).length;
2502
+ expect(count).toBe(20);
2503
+ }, 3000);
2504
+ });
2505
+
2506
+ describe("handleStickerMessage — video sticker branch (L835 TRUE)", () => {
2507
+ it("includes (video sticker) when is_animated=false and is_video=true", async () => {
2508
+ executeMock.mockResolvedValue({
2509
+ text: "",
2510
+ durationMs: 10,
2511
+ inputTokens: 1,
2512
+ outputTokens: 1,
2513
+ cacheRead: 0,
2514
+ cacheWrite: 0,
2515
+ bridgeMessageCount: 0,
2516
+ });
2517
+
2518
+ const chatId = 99300;
2519
+ const ctx = {
2520
+ chat: { id: chatId, type: "private" },
2521
+ message: {
2522
+ message_id: 1300,
2523
+ sticker: {
2524
+ file_id: "vs123",
2525
+ emoji: "🎬",
2526
+ set_name: "",
2527
+ is_animated: false,
2528
+ is_video: true,
2529
+ },
2530
+ reply_to_message: null,
2531
+ },
2532
+ me: { id: 999, username: "testbot" },
2533
+ from: { id: 99, first_name: "Victor" },
2534
+ } as any;
2535
+
2536
+ const before = executeMock.mock.calls.length;
2537
+ await handleStickerMessage(ctx, mockBot, mockConfig);
2538
+ await new Promise((r) => setTimeout(r, 700));
2539
+
2540
+ const calls = executeMock.mock.calls
2541
+ .slice(before)
2542
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2543
+ expect(calls.length).toBe(1);
2544
+ expect((calls[0][0] as { prompt: string }).prompt).toContain(
2545
+ "video sticker",
2546
+ );
2547
+ }, 3000);
2548
+ });
2549
+
2550
+ describe("processAndReply — group message without senderId (L552 FALSE branch)", () => {
2551
+ it("skips enrichGroupPrompt when senderId is undefined in group", async () => {
2552
+ const { enrichGroupPrompt } = await import("../core/prompt-builder.js");
2553
+ (enrichGroupPrompt as ReturnType<typeof vi.fn>).mockClear();
2554
+
2555
+ executeMock.mockResolvedValueOnce({
2556
+ text: "",
2557
+ durationMs: 10,
2558
+ inputTokens: 1,
2559
+ outputTokens: 1,
2560
+ cacheRead: 0,
2561
+ cacheWrite: 0,
2562
+ bridgeMessageCount: 0,
2563
+ });
2564
+
2565
+ const chatId = 99400;
2566
+ const ctx = {
2567
+ chat: { id: chatId, type: "supergroup", title: "Test Group" },
2568
+ message: {
2569
+ text: "@testbot anonymous message",
2570
+ message_id: 1400,
2571
+ reply_to_message: null,
2572
+ },
2573
+ me: { id: 999, username: "testbot" },
2574
+ from: undefined, // no sender info → senderId=undefined
2575
+ } as any;
2576
+
2577
+ const before = executeMock.mock.calls.length;
2578
+ await handleTextMessage(ctx, mockBot, mockConfig);
2579
+ await new Promise((r) => setTimeout(r, 700));
2580
+
2581
+ // enrichGroupPrompt should NOT have been called (senderId falsy)
2582
+ expect(enrichGroupPrompt).not.toHaveBeenCalled();
2583
+ // But message was still processed
2584
+ const calls = executeMock.mock.calls
2585
+ .slice(before)
2586
+ .filter((c) => (c[0] as { chatId: string }).chatId === String(chatId));
2587
+ expect(calls.length).toBe(1);
2588
+ }, 3000);
2589
+ });
2590
+
2591
+ describe("processAndReply — suppressed fallback text logged (L572 TRUE branch)", () => {
2592
+ it("logs when bridgeMessageCount=0 and result.text is non-empty", async () => {
2593
+ const { log } = await import("../util/log.js");
2594
+ (log as ReturnType<typeof vi.fn>).mockClear();
2595
+
2596
+ executeMock.mockResolvedValueOnce({
2597
+ text: "internal reasoning only",
2598
+ durationMs: 10,
2599
+ inputTokens: 1,
2600
+ outputTokens: 1,
2601
+ cacheRead: 0,
2602
+ cacheWrite: 0,
2603
+ bridgeMessageCount: 0,
2604
+ });
2605
+
2606
+ const ctx = {
2607
+ chat: { id: 99600, type: "private" },
2608
+ message: {
2609
+ text: "test suppressed fallback",
2610
+ message_id: 1600,
2611
+ reply_to_message: null,
2612
+ },
2613
+ me: { id: 999, username: "testbot" },
2614
+ from: { id: 9902000, first_name: "Tom" },
2615
+ } as any;
2616
+
2617
+ await handleTextMessage(ctx, mockBot, mockConfig);
2618
+ await new Promise((r) => setTimeout(r, 700));
2619
+
2620
+ const logCalls = (log as ReturnType<typeof vi.fn>).mock.calls;
2621
+ const suppressedLog = logCalls.find((c: unknown[]) =>
2622
+ String(c[1]).includes("Suppressed fallback"),
2623
+ );
2624
+ expect(suppressedLog).toBeDefined();
2625
+ }, 3000);
2626
+ });
2627
+
2628
+ describe("flushQueue — setMessageReaction clear fails (L344 warn paths)", () => {
2629
+ afterEach(() => {
2630
+ mockBot.api.setMessageReaction = vi.fn(async () => {});
2631
+ });
2632
+
2633
+ it("logs warning with err.message when clear reaction throws Error (L344 TRUE branch)", async () => {
2634
+ const { logWarn } = await import("../util/log.js");
2635
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
2636
+
2637
+ mockBot.api.setMessageReaction = vi.fn(async () => {
2638
+ throw new Error("setReaction failed");
2639
+ });
2640
+ executeMock.mockResolvedValue({
2641
+ text: "",
2642
+ durationMs: 10,
2643
+ inputTokens: 1,
2644
+ outputTokens: 1,
2645
+ cacheRead: 0,
2646
+ cacheWrite: 0,
2647
+ bridgeMessageCount: 0,
2648
+ });
2649
+
2650
+ // 2 messages to same chat — 2nd adds msgId to queuedReactionMsgIds
2651
+ const chatId = 99500;
2652
+ const makeCtx = (msgId: number, userId: number) =>
2653
+ ({
2654
+ chat: { id: chatId, type: "private" },
2655
+ message: { text: `msg`, message_id: msgId, reply_to_message: null },
2656
+ me: { id: 999, username: "testbot" },
2657
+ from: { id: userId, first_name: "R" },
2658
+ }) as any;
2659
+
2660
+ await handleTextMessage(makeCtx(3001, 9901000), mockBot, mockConfig);
2661
+ await handleTextMessage(makeCtx(3002, 9901001), mockBot, mockConfig);
2662
+ await new Promise((r) => setTimeout(r, 700));
2663
+
2664
+ const reactionWarn = (logWarn as ReturnType<typeof vi.fn>).mock.calls.find(
2665
+ (c: unknown[]) => String(c[1]).includes("Failed to clear reaction"),
2666
+ );
2667
+ expect(reactionWarn).toBeDefined();
2668
+ expect(String(reactionWarn![1])).toContain("setReaction failed");
2669
+ }, 3000);
2670
+
2671
+ it("logs warning with raw err when clear reaction throws non-Error (L344 FALSE branch)", async () => {
2672
+ const { logWarn } = await import("../util/log.js");
2673
+ (logWarn as ReturnType<typeof vi.fn>).mockClear();
2674
+
2675
+ // eslint-disable-next-line @typescript-eslint/no-throw-literal
2676
+ mockBot.api.setMessageReaction = vi.fn(async () => {
2677
+ throw "reaction-string-err";
2678
+ });
2679
+ executeMock.mockResolvedValue({
2680
+ text: "",
2681
+ durationMs: 10,
2682
+ inputTokens: 1,
2683
+ outputTokens: 1,
2684
+ cacheRead: 0,
2685
+ cacheWrite: 0,
2686
+ bridgeMessageCount: 0,
2687
+ });
2688
+
2689
+ const chatId = 99501;
2690
+ const makeCtx = (msgId: number, userId: number) =>
2691
+ ({
2692
+ chat: { id: chatId, type: "private" },
2693
+ message: { text: `msg`, message_id: msgId, reply_to_message: null },
2694
+ me: { id: 999, username: "testbot" },
2695
+ from: { id: userId, first_name: "R" },
2696
+ }) as any;
2697
+
2698
+ await handleTextMessage(makeCtx(3003, 9901002), mockBot, mockConfig);
2699
+ await handleTextMessage(makeCtx(3004, 9901003), mockBot, mockConfig);
2700
+ await new Promise((r) => setTimeout(r, 700));
2701
+
2702
+ const reactionWarn = (logWarn as ReturnType<typeof vi.fn>).mock.calls.find(
2703
+ (c: unknown[]) =>
2704
+ String(c[1]).includes("Failed to clear reaction") &&
2705
+ String(c[1]).includes("reaction-string-err"),
2706
+ );
2707
+ expect(reactionWarn).toBeDefined();
2708
+ }, 3000);
2709
+ });
2710
+
2711
+ describe("isUserRateLimited — expired timestamps evicted via shift() (L272)", () => {
2712
+ it("removes timestamps older than RATE_LIMIT_WINDOW_MS", async () => {
2713
+ vi.useFakeTimers();
2714
+
2715
+ executeMock.mockResolvedValue({
2716
+ text: "",
2717
+ durationMs: 5,
2718
+ inputTokens: 1,
2719
+ outputTokens: 1,
2720
+ cacheRead: 0,
2721
+ cacheWrite: 0,
2722
+ bridgeMessageCount: 0,
2723
+ });
2724
+
2725
+ const chatId = 99700;
2726
+ const userId = 9903000; // unique userId — no prior timestamps
2727
+ const makeCtx = (msgId: number) =>
2728
+ ({
2729
+ chat: { id: chatId, type: "private" },
2730
+ message: {
2731
+ text: `old-timer ${msgId}`,
2732
+ message_id: msgId,
2733
+ reply_to_message: null,
2734
+ },
2735
+ me: { id: 999, username: "testbot" },
2736
+ from: { id: userId, first_name: "OldTimer" },
2737
+ }) as any;
2738
+
2739
+ // T=0: send message — adds timestamp[0]=0 for userId
2740
+ await handleTextMessage(makeCtx(1700), mockBot, mockConfig);
2741
+ await vi.advanceTimersByTimeAsync(600); // fire debounce (500ms)
2742
+
2743
+ // Advance past RATE_LIMIT_WINDOW_MS (60s) → timestamp[0] becomes stale
2744
+ await vi.advanceTimersByTimeAsync(61_000);
2745
+
2746
+ // T≈62s: second message — while loop sees timestamps[0] < now-60000 → shift() (L272)
2747
+ await handleTextMessage(makeCtx(1701), mockBot, mockConfig);
2748
+ await vi.advanceTimersByTimeAsync(600);
2749
+
2750
+ vi.clearAllTimers();
2751
+ vi.useRealTimers();
2752
+
2753
+ // Both messages processed (second was not blocked by stale timestamp)
2754
+ const calls = executeMock.mock.calls.filter(
2755
+ (c) => (c[0] as { chatId: string }).chatId === String(chatId),
2756
+ );
2757
+ expect(calls.length).toBe(2);
2758
+ }, 10000);
2759
+ });
2760
+
2761
+ describe("isUserRateLimited — userMessageTimestamps eviction when size > 5000 (L278-284)", () => {
2762
+ it("evicts stale entries and hits delete+break branches", async () => {
2763
+ // Use fake timers starting at T=0 — all 5000 initial entries get timestamps[0]=0
2764
+ vi.useFakeTimers({ now: 0 });
2765
+
2766
+ executeMock.mockResolvedValue({
2767
+ text: "",
2768
+ durationMs: 5,
2769
+ inputTokens: 1,
2770
+ outputTokens: 1,
2771
+ cacheRead: 0,
2772
+ cacheWrite: 0,
2773
+ bridgeMessageCount: 0,
2774
+ });
2775
+
2776
+ const BASE_UID = 200_000_000; // far outside all other test userId ranges
2777
+
2778
+ // Fire 5000 handleTextMessage calls synchronously — each runs isUserRateLimited(BASE+i)
2779
+ // at T=0 before the first await (downloadReplyPhoto). The map accumulates 5000 entries.
2780
+ for (let i = 0; i < 5_000; i++) {
2781
+ void handleTextMessage(
2782
+ {
2783
+ chat: { id: 88_000_001, type: "private" },
2784
+ message: { text: "x", message_id: i + 1, reply_to_message: null },
2785
+ me: { id: 999, username: "testbot" },
2786
+ from: { id: BASE_UID + i, first_name: "X" },
2787
+ } as any,
2788
+ mockBot,
2789
+ mockConfig,
2790
+ );
2791
+ }
2792
+
2793
+ // Advance fake time by 11 minutes — makes all T=0 timestamps stale
2794
+ // (cutoff will be T+1min; T=0 entries are below cutoff)
2795
+ vi.advanceTimersByTime(11 * 60_000);
2796
+
2797
+ // 5001st unique user at T+11min — isUserRateLimited sees size > 5000 → cleanup runs
2798
+ // All 5000 T=0 entries (below cutoff) are deleted until size ≤ 2500 → break (L284)
2799
+ void handleTextMessage(
2800
+ {
2801
+ chat: { id: 88_000_002, type: "private" },
2802
+ message: {
2803
+ text: "trigger",
2804
+ message_id: 10_000,
2805
+ reply_to_message: null,
2806
+ },
2807
+ me: { id: 999, username: "testbot" },
2808
+ from: { id: BASE_UID + 5_001, first_name: "T" },
2809
+ } as any,
2810
+ mockBot,
2811
+ mockConfig,
2812
+ );
2813
+
2814
+ // Drain pending microtasks and fire debounce timers to clean up async state
2815
+ await vi.advanceTimersByTimeAsync(1_000);
2816
+ vi.clearAllTimers();
2817
+ vi.useRealTimers();
2818
+ // Lines 278-284 (eviction loop with delete and break) are now covered
2819
+ }, 15_000);
2820
+ });