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.
- package/LICENSE +21 -0
- package/README.md +1 -0
- package/package.json +15 -11
- package/prompts/dream.md +7 -3
- package/prompts/heartbeat.md +30 -0
- package/prompts/identity.md +1 -0
- package/prompts/teams.md +3 -0
- package/prompts/telegram.md +1 -0
- package/src/__tests__/chat-settings.test.ts +108 -2
- package/src/__tests__/cleanup-registry.test.ts +58 -0
- package/src/__tests__/config.test.ts +118 -52
- package/src/__tests__/cron-store-extended.test.ts +661 -0
- package/src/__tests__/cron-store.test.ts +145 -11
- package/src/__tests__/daily-log.test.ts +224 -13
- package/src/__tests__/dispatcher.test.ts +424 -23
- package/src/__tests__/dream.test.ts +1028 -0
- package/src/__tests__/errors-extended.test.ts +428 -0
- package/src/__tests__/errors.test.ts +95 -3
- package/src/__tests__/fuzz.test.ts +87 -15
- package/src/__tests__/gateway-actions.test.ts +1174 -433
- package/src/__tests__/gateway-http.test.ts +210 -19
- package/src/__tests__/gateway-retry.test.ts +359 -0
- package/src/__tests__/gateway-withRetry-extended.test.ts +343 -0
- package/src/__tests__/graph.test.ts +830 -0
- package/src/__tests__/handlers-stream.test.ts +208 -0
- package/src/__tests__/handlers.test.ts +2539 -70
- package/src/__tests__/heartbeat.test.ts +364 -0
- package/src/__tests__/history-extended.test.ts +775 -0
- package/src/__tests__/history-persistence.test.ts +74 -19
- package/src/__tests__/history.test.ts +113 -79
- package/src/__tests__/integration.test.ts +43 -8
- package/src/__tests__/log-init.test.ts +129 -0
- package/src/__tests__/log.test.ts +23 -5
- package/src/__tests__/media-index.test.ts +317 -35
- package/src/__tests__/plugin.test.ts +314 -0
- package/src/__tests__/prompt-builder-extended.test.ts +296 -0
- package/src/__tests__/prompt-builder.test.ts +44 -9
- package/src/__tests__/sessions.test.ts +258 -4
- package/src/__tests__/storage-save-errors.test.ts +342 -0
- package/src/__tests__/teams-frontend.test.ts +526 -31
- package/src/__tests__/telegram-formatting.test.ts +82 -0
- package/src/__tests__/terminal-commands.test.ts +208 -1
- package/src/__tests__/terminal-renderer.test.ts +223 -0
- package/src/__tests__/time.test.ts +107 -0
- package/src/__tests__/workspace-migrate.test.ts +256 -0
- package/src/__tests__/workspace.test.ts +63 -1
- package/src/backend/claude-sdk/tools.ts +64 -18
- package/src/bootstrap.ts +14 -14
- package/src/cli.ts +440 -125
- package/src/core/cron.ts +20 -5
- package/src/core/dispatcher.ts +27 -9
- package/src/core/dream.ts +79 -24
- package/src/core/errors.ts +12 -2
- package/src/core/gateway-actions.ts +182 -46
- package/src/core/gateway.ts +93 -41
- package/src/core/heartbeat.ts +515 -0
- package/src/core/plugin.ts +1 -1
- package/src/core/prompt-builder.ts +1 -4
- package/src/core/pulse.ts +4 -3
- package/src/frontend/teams/actions.ts +3 -1
- package/src/frontend/teams/formatting.ts +47 -8
- package/src/frontend/teams/graph.ts +35 -11
- package/src/frontend/teams/index.ts +155 -57
- package/src/frontend/teams/tools.ts +4 -6
- package/src/frontend/telegram/actions.ts +358 -82
- package/src/frontend/telegram/admin.ts +162 -72
- package/src/frontend/telegram/callbacks.ts +16 -10
- package/src/frontend/telegram/commands.ts +37 -21
- package/src/frontend/telegram/formatting.ts +2 -4
- package/src/frontend/telegram/handlers.ts +262 -66
- package/src/frontend/telegram/index.ts +39 -14
- package/src/frontend/telegram/middleware.ts +14 -4
- package/src/frontend/telegram/userbot.ts +16 -4
- package/src/frontend/terminal/renderer.ts +1 -4
- package/src/index.ts +28 -4
- package/src/storage/chat-settings.ts +32 -9
- package/src/storage/cron-store.ts +53 -11
- package/src/storage/daily-log.ts +72 -19
- package/src/storage/history.ts +39 -21
- package/src/storage/media-index.ts +37 -12
- package/src/storage/sessions.ts +3 -2
- package/src/util/cleanup-registry.ts +34 -0
- package/src/util/config.ts +85 -23
- package/src/util/log.ts +47 -17
- package/src/util/paths.ts +10 -0
- package/src/util/time.ts +29 -6
- package/src/util/watchdog.ts +5 -1
- 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.
|
|
4
|
-
|
|
3
|
+
const executeMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
|
|
5
|
+
vi.mock("../core/dispatcher.js", () => ({
|
|
6
|
+
execute: executeMock,
|
|
5
7
|
}));
|
|
6
|
-
vi.mock("
|
|
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("
|
|
12
|
+
vi.mock("../storage/daily-log.js", () => ({
|
|
11
13
|
appendDailyLog: vi.fn(),
|
|
14
|
+
appendDailyLogResponse: vi.fn(),
|
|
12
15
|
}));
|
|
13
|
-
vi.mock("
|
|
16
|
+
vi.mock("../util/watchdog.js", () => ({
|
|
14
17
|
recordMessageProcessed: vi.fn(),
|
|
15
18
|
recordError: vi.fn(),
|
|
16
19
|
}));
|
|
17
|
-
vi.mock("
|
|
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("
|
|
24
|
-
classify: vi.fn((e: unknown) =>
|
|
25
|
-
|
|
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("
|
|
35
|
+
vi.mock("../storage/history.js", () => ({
|
|
29
36
|
setMessageFilePath: vi.fn(),
|
|
30
37
|
getRecentBySenderId: vi.fn(() => []),
|
|
31
38
|
}));
|
|
32
|
-
vi.mock("
|
|
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 {
|
|
42
|
-
|
|
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 = (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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: {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
:
|
|
253
|
-
|
|
254
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
},
|
|
302
|
-
}
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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(
|
|
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: {
|
|
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 } },
|
|
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
|
+
});
|