@llblab/pi-telegram 0.2.8 → 0.2.10

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/tests/api.test.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import assert from "node:assert/strict";
7
- import { mkdtemp, readFile } from "node:fs/promises";
7
+ import { mkdir, mkdtemp, readFile, readdir, utimes, writeFile } from "node:fs/promises";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import test from "node:test";
@@ -12,6 +12,8 @@ import test from "node:test";
12
12
  import {
13
13
  answerTelegramCallbackQuery,
14
14
  callTelegram,
15
+ callTelegramMultipart,
16
+ cleanupTelegramTempFiles,
15
17
  createTelegramApiClient,
16
18
  downloadTelegramFile,
17
19
  readTelegramConfig,
@@ -33,6 +35,21 @@ test("Telegram config helpers persist and reload config", async () => {
33
35
  assert.match(raw, /demo_bot/);
34
36
  });
35
37
 
38
+ test("Telegram temp cleanup removes only stale files", async () => {
39
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-cleanup-"));
40
+ const oldFile = join(tempDir, "old.txt");
41
+ const freshFile = join(tempDir, "fresh.txt");
42
+ const nestedDir = join(tempDir, "nested");
43
+ await writeFile(oldFile, "old", "utf8");
44
+ await writeFile(freshFile, "fresh", "utf8");
45
+ await mkdir(nestedDir);
46
+ await writeFile(join(nestedDir, "keep.txt"), "keep", "utf8");
47
+ await utimes(oldFile, new Date(1_000), new Date(1_000));
48
+ await utimes(freshFile, new Date(10_000), new Date(10_000));
49
+ assert.equal(await cleanupTelegramTempFiles(tempDir, 5_000, 11_000), 1);
50
+ assert.deepEqual((await readdir(tempDir)).sort(), ["fresh.txt", "nested"]);
51
+ });
52
+
36
53
  test("Telegram API helpers reject missing bot token for direct calls", async () => {
37
54
  await assert.rejects(() => callTelegram(undefined, "getMe", {}), {
38
55
  message: "Telegram bot token is not configured",
@@ -51,6 +68,231 @@ test("Telegram API helpers reject missing bot token for direct calls", async ()
51
68
  );
52
69
  });
53
70
 
71
+ test("Telegram API helpers include HTTP status details for failed responses", async () => {
72
+ const originalFetch = globalThis.fetch;
73
+ globalThis.fetch = (async () => {
74
+ return {
75
+ ok: false,
76
+ status: 429,
77
+ text: async () => JSON.stringify({ ok: false, description: "Too Many Requests" }),
78
+ } as Response;
79
+ }) as typeof fetch;
80
+ try {
81
+ await assert.rejects(
82
+ () => callTelegram("123:abc", "sendMessage", {}, { maxAttempts: 1 }),
83
+ {
84
+ message: "Telegram API sendMessage failed: HTTP 429: Too Many Requests",
85
+ },
86
+ );
87
+ } finally {
88
+ globalThis.fetch = originalFetch;
89
+ }
90
+ });
91
+
92
+ test("Telegram API helpers retry 429 and 5xx responses", async () => {
93
+ const originalFetch = globalThis.fetch;
94
+ const sleeps: number[] = [];
95
+ let calls = 0;
96
+ globalThis.fetch = (async () => {
97
+ calls += 1;
98
+ if (calls === 1) {
99
+ return {
100
+ ok: false,
101
+ status: 429,
102
+ headers: new Headers({ "retry-after": "2" }),
103
+ text: async () => JSON.stringify({ ok: false, description: "Too Many Requests" }),
104
+ } as Response;
105
+ }
106
+ if (calls === 2) {
107
+ return {
108
+ ok: false,
109
+ status: 502,
110
+ text: async () => JSON.stringify({ ok: false, description: "Bad Gateway" }),
111
+ } as Response;
112
+ }
113
+ return {
114
+ ok: true,
115
+ status: 200,
116
+ text: async () => JSON.stringify({ ok: true, result: "sent" }),
117
+ } as Response;
118
+ }) as typeof fetch;
119
+ try {
120
+ const result = await callTelegram<string>("123:abc", "sendMessage", {}, {
121
+ retryBaseDelayMs: 10,
122
+ sleep: async (ms) => {
123
+ sleeps.push(ms);
124
+ },
125
+ });
126
+ assert.equal(result, "sent");
127
+ assert.equal(calls, 3);
128
+ assert.deepEqual(sleeps, [2000, 20]);
129
+ } finally {
130
+ globalThis.fetch = originalFetch;
131
+ }
132
+ });
133
+
134
+ test("Telegram multipart API rebuilds forms for retryable responses", async () => {
135
+ const originalFetch = globalThis.fetch;
136
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-upload-"));
137
+ const filePath = join(tempDir, "demo.txt");
138
+ await writeFile(filePath, "hello", "utf8");
139
+ const contentTypes: string[] = [];
140
+ let calls = 0;
141
+ globalThis.fetch = (async (_input, init) => {
142
+ calls += 1;
143
+ contentTypes.push((init?.body as FormData).get("document") instanceof Blob ? "blob" : "missing");
144
+ if (calls === 1) {
145
+ return {
146
+ ok: false,
147
+ status: 500,
148
+ text: async () => JSON.stringify({ ok: false, description: "Server Error" }),
149
+ } as Response;
150
+ }
151
+ return {
152
+ ok: true,
153
+ status: 200,
154
+ text: async () => JSON.stringify({ ok: true, result: true }),
155
+ } as Response;
156
+ }) as typeof fetch;
157
+ try {
158
+ assert.equal(
159
+ await callTelegramMultipart<boolean>(
160
+ "123:abc",
161
+ "sendDocument",
162
+ { chat_id: "1" },
163
+ "document",
164
+ filePath,
165
+ "demo.txt",
166
+ { retryBaseDelayMs: 0, sleep: async () => {} },
167
+ ),
168
+ true,
169
+ );
170
+ assert.equal(calls, 2);
171
+ assert.deepEqual(contentTypes, ["blob", "blob"]);
172
+ } finally {
173
+ globalThis.fetch = originalFetch;
174
+ }
175
+ });
176
+
177
+ test("Telegram file downloads use unique sanitized temp file names", async () => {
178
+ const originalFetch = globalThis.fetch;
179
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-"));
180
+ globalThis.fetch = (async (input) => {
181
+ const url = typeof input === "string" ? input : input.toString();
182
+ if (url.includes("/getFile")) {
183
+ return {
184
+ ok: true,
185
+ status: 200,
186
+ text: async () =>
187
+ JSON.stringify({ ok: true, result: { file_path: "files/demo" } }),
188
+ } as Response;
189
+ }
190
+ return new Response("hello", { status: 200 });
191
+ }) as typeof fetch;
192
+ try {
193
+ const path = await downloadTelegramFile(
194
+ "123:abc",
195
+ "file-id",
196
+ "bad name?.txt",
197
+ tempDir,
198
+ );
199
+ assert.match(path, /[0-9a-f-]{36}-bad_name_\.txt$/);
200
+ assert.equal(await readFile(path, "utf8"), "hello");
201
+ } finally {
202
+ globalThis.fetch = originalFetch;
203
+ }
204
+ });
205
+
206
+ test("Telegram file downloads reject files above configured limits", async () => {
207
+ const originalFetch = globalThis.fetch;
208
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-limit-"));
209
+ let calls = 0;
210
+ globalThis.fetch = (async (input) => {
211
+ calls += 1;
212
+ const url = typeof input === "string" ? input : input.toString();
213
+ if (url.includes("/getFile")) {
214
+ return {
215
+ ok: true,
216
+ status: 200,
217
+ text: async () =>
218
+ JSON.stringify({
219
+ ok: true,
220
+ result: { file_path: "files/demo", file_size: 10 },
221
+ }),
222
+ } as Response;
223
+ }
224
+ return new Response("too large", { status: 200 });
225
+ }) as typeof fetch;
226
+ try {
227
+ await assert.rejects(
228
+ () =>
229
+ downloadTelegramFile(
230
+ "123:abc",
231
+ "file-id",
232
+ "demo.txt",
233
+ tempDir,
234
+ { maxFileSizeBytes: 5 },
235
+ ),
236
+ { message: "Telegram file exceeds size limit (10 bytes > 5 bytes)" },
237
+ );
238
+ assert.equal(calls, 1);
239
+ assert.deepEqual(await readdir(tempDir), []);
240
+ } finally {
241
+ globalThis.fetch = originalFetch;
242
+ }
243
+ });
244
+
245
+ test("Telegram streaming downloads remove partial files after limit failures", async () => {
246
+ const originalFetch = globalThis.fetch;
247
+ const tempDir = await mkdtemp(join(tmpdir(), "pi-telegram-download-partial-"));
248
+ globalThis.fetch = (async (input) => {
249
+ const url = typeof input === "string" ? input : input.toString();
250
+ if (url.includes("/getFile")) {
251
+ return {
252
+ ok: true,
253
+ status: 200,
254
+ text: async () =>
255
+ JSON.stringify({ ok: true, result: { file_path: "files/demo" } }),
256
+ } as Response;
257
+ }
258
+ return new Response("too large", { status: 200 });
259
+ }) as typeof fetch;
260
+ try {
261
+ await assert.rejects(
262
+ () =>
263
+ downloadTelegramFile(
264
+ "123:abc",
265
+ "file-id",
266
+ "demo.txt",
267
+ tempDir,
268
+ { maxFileSizeBytes: 5 },
269
+ ),
270
+ { message: "Telegram file exceeds size limit (9 bytes > 5 bytes)" },
271
+ );
272
+ assert.deepEqual(await readdir(tempDir), []);
273
+ } finally {
274
+ globalThis.fetch = originalFetch;
275
+ }
276
+ });
277
+
278
+ test("Telegram API helpers reject malformed successful responses", async () => {
279
+ const originalFetch = globalThis.fetch;
280
+ globalThis.fetch = (async () => {
281
+ return {
282
+ ok: true,
283
+ status: 200,
284
+ text: async () => "not json",
285
+ } as Response;
286
+ }) as typeof fetch;
287
+ try {
288
+ await assert.rejects(() => callTelegram("123:abc", "getMe", {}), {
289
+ message: "Telegram API getMe returned invalid JSON",
290
+ });
291
+ } finally {
292
+ globalThis.fetch = originalFetch;
293
+ }
294
+ });
295
+
54
296
  test("answerTelegramCallbackQuery ignores Telegram API failures", async () => {
55
297
  const originalFetch = globalThis.fetch;
56
298
  globalThis.fetch = (async () => {
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Regression tests for Telegram command helpers
3
+ * Covers slash-command normalization, bot suffix stripping, arguments, and non-command input
4
+ */
5
+
6
+ import assert from "node:assert/strict";
7
+ import test from "node:test";
8
+
9
+ import {
10
+ buildTelegramCommandAction,
11
+ executeTelegramCommandAction,
12
+ parseTelegramCommand,
13
+ } from "../lib/commands.ts";
14
+
15
+ test("Command helpers parse slash commands with args", () => {
16
+ assert.deepEqual(parseTelegramCommand(" /Model@DemoBot claude opus "), {
17
+ name: "model",
18
+ args: "claude opus",
19
+ });
20
+ assert.deepEqual(parseTelegramCommand("/status"), {
21
+ name: "status",
22
+ args: "",
23
+ });
24
+ });
25
+
26
+ test("Command helpers ignore non-command input and empty names", () => {
27
+ assert.equal(parseTelegramCommand("hello /status"), undefined);
28
+ assert.equal(parseTelegramCommand("/"), undefined);
29
+ });
30
+
31
+ test("Command helpers build command actions", () => {
32
+ assert.deepEqual(buildTelegramCommandAction("stop"), { kind: "stop" });
33
+ assert.deepEqual(buildTelegramCommandAction("compact"), { kind: "compact" });
34
+ assert.deepEqual(buildTelegramCommandAction("status"), { kind: "status" });
35
+ assert.deepEqual(buildTelegramCommandAction("model"), { kind: "model" });
36
+ assert.deepEqual(buildTelegramCommandAction("help"), {
37
+ kind: "help",
38
+ commandName: "help",
39
+ });
40
+ assert.deepEqual(buildTelegramCommandAction("start"), {
41
+ kind: "help",
42
+ commandName: "start",
43
+ });
44
+ assert.deepEqual(buildTelegramCommandAction("unknown"), { kind: "ignore" });
45
+ assert.deepEqual(buildTelegramCommandAction(undefined), { kind: "ignore" });
46
+ });
47
+
48
+ test("Command helpers execute command actions through provided handlers", async () => {
49
+ const events: string[] = [];
50
+ const deps = {
51
+ handleStop: async () => {
52
+ events.push("stop");
53
+ },
54
+ handleCompact: async () => {
55
+ events.push("compact");
56
+ },
57
+ handleStatus: async () => {
58
+ events.push("status");
59
+ },
60
+ handleModel: async () => {
61
+ events.push("model");
62
+ },
63
+ handleHelp: async (_message: unknown, commandName: "help" | "start") => {
64
+ events.push(`help:${commandName}`);
65
+ },
66
+ };
67
+ assert.equal(
68
+ await executeTelegramCommandAction({ kind: "ignore" }, {}, {}, deps),
69
+ false,
70
+ );
71
+ assert.equal(
72
+ await executeTelegramCommandAction({ kind: "stop" }, {}, {}, deps),
73
+ true,
74
+ );
75
+ assert.equal(
76
+ await executeTelegramCommandAction(
77
+ { kind: "help", commandName: "start" },
78
+ {},
79
+ {},
80
+ deps,
81
+ ),
82
+ true,
83
+ );
84
+ assert.deepEqual(events, ["stop", "help:start"]);
85
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Regression tests for Telegram media and text extraction helpers
3
- * Covers inbound file-info collection, text extraction, id collection, and history formatting
3
+ * Covers inbound file-info collection, text extraction, media groups, id collection, and history formatting
4
4
  */
5
5
 
6
6
  import assert from "node:assert/strict";
@@ -12,7 +12,11 @@ import {
12
12
  extractFirstTelegramMessageText,
13
13
  extractTelegramMessagesText,
14
14
  formatTelegramHistoryText,
15
+ getTelegramMediaGroupKey,
15
16
  guessMediaType,
17
+ queueTelegramMediaGroupMessage,
18
+ removePendingTelegramMediaGroupMessages,
19
+ type TelegramMediaGroupState,
16
20
  } from "../lib/media.ts";
17
21
 
18
22
  test("Media helpers collect file infos across Telegram message variants", () => {
@@ -75,3 +79,88 @@ test("Media helpers infer outgoing image media types from file paths", () => {
75
79
  assert.equal(guessMediaType("/tmp/demo.png"), "image/png");
76
80
  assert.equal(guessMediaType("/tmp/demo.txt"), undefined);
77
81
  });
82
+
83
+ test("Media helpers key messages by chat and media group", () => {
84
+ assert.equal(
85
+ getTelegramMediaGroupKey({
86
+ message_id: 1,
87
+ chat: { id: 7 },
88
+ media_group_id: "album",
89
+ }),
90
+ "7:album",
91
+ );
92
+ assert.equal(
93
+ getTelegramMediaGroupKey({ message_id: 1, chat: { id: 7 } }),
94
+ undefined,
95
+ );
96
+ });
97
+
98
+ test("Media helpers replace debounce timers and dispatch grouped messages", () => {
99
+ const groups = new Map<
100
+ string,
101
+ TelegramMediaGroupState<{
102
+ message_id: number;
103
+ chat: { id: number };
104
+ media_group_id?: string;
105
+ }>
106
+ >();
107
+ const cleared: number[] = [];
108
+ const callbacks: Array<() => void> = [];
109
+ const dispatched: number[][] = [];
110
+ let nextTimer = 1;
111
+ const setTimer = (callback: () => void): ReturnType<typeof setTimeout> => {
112
+ callbacks.push(callback);
113
+ return nextTimer++ as unknown as ReturnType<typeof setTimeout>;
114
+ };
115
+ const clearTimer = (timer: ReturnType<typeof setTimeout>): void => {
116
+ cleared.push(timer as unknown as number);
117
+ };
118
+ assert.equal(
119
+ queueTelegramMediaGroupMessage({
120
+ message: { message_id: 1, chat: { id: 7 }, media_group_id: "album" },
121
+ groups,
122
+ debounceMs: 100,
123
+ setTimer,
124
+ clearTimer,
125
+ dispatchMessages: (messages) =>
126
+ dispatched.push(messages.map((message) => message.message_id)),
127
+ }),
128
+ true,
129
+ );
130
+ queueTelegramMediaGroupMessage({
131
+ message: { message_id: 2, chat: { id: 7 }, media_group_id: "album" },
132
+ groups,
133
+ debounceMs: 100,
134
+ setTimer,
135
+ clearTimer,
136
+ dispatchMessages: (messages) =>
137
+ dispatched.push(messages.map((message) => message.message_id)),
138
+ });
139
+ assert.deepEqual(cleared, [1]);
140
+ callbacks.at(-1)?.();
141
+ assert.deepEqual(dispatched, [[1, 2]]);
142
+ assert.equal(groups.size, 0);
143
+ });
144
+
145
+ test("Media helpers remove pending groups by message id", () => {
146
+ const groups = new Map<
147
+ string,
148
+ TelegramMediaGroupState<{ message_id: number; chat: { id: number } }>
149
+ >();
150
+ groups.set("7:album", {
151
+ messages: [
152
+ { message_id: 1, chat: { id: 7 } },
153
+ { message_id: 2, chat: { id: 7 } },
154
+ ],
155
+ flushTimer: 10 as unknown as ReturnType<typeof setTimeout>,
156
+ });
157
+ const cleared: number[] = [];
158
+ assert.equal(
159
+ removePendingTelegramMediaGroupMessages(groups, [2], (timer) => {
160
+ cleared.push(timer as unknown as number);
161
+ }),
162
+ 1,
163
+ );
164
+ assert.deepEqual(cleared, [10]);
165
+ assert.equal(groups.size, 0);
166
+ });
@@ -144,7 +144,9 @@ test("Menu helpers apply menu mutations and resolve model selections", () => {
144
144
  assert.equal(state.page, 2);
145
145
  assert.equal(applyTelegramModelPageSelection(state, "2"), "unchanged");
146
146
  assert.equal(applyTelegramModelPageSelection(state, "bad"), "invalid");
147
- assert.deepEqual(getTelegramModelSelection(state, "bad"), { kind: "invalid" });
147
+ assert.deepEqual(getTelegramModelSelection(state, "bad"), {
148
+ kind: "invalid",
149
+ });
148
150
  assert.deepEqual(getTelegramModelSelection(state, "9"), { kind: "missing" });
149
151
  assert.equal(getTelegramModelSelection(state, "0").kind, "selected");
150
152
  });
@@ -174,7 +176,11 @@ test("Menu helpers derive normalized menu pages without mutating state", () => {
174
176
 
175
177
  test("Menu helpers build model callback plans for paging, selection, and restart modes", () => {
176
178
  const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
177
- const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const;
179
+ const modelB = {
180
+ provider: "anthropic",
181
+ id: "claude-3",
182
+ reasoning: false,
183
+ } as const;
178
184
  const state = {
179
185
  chatId: 1,
180
186
  messageId: 2,
@@ -227,7 +233,8 @@ test("Menu helpers build model callback plans for paging, selection, and restart
227
233
  kind: "switch-model",
228
234
  selection: state.allModels[1],
229
235
  mode: "restart-after-tool",
230
- callbackText: "Switched to claude-3. Restarting after the current tool finishes…",
236
+ callbackText:
237
+ "Switched to claude-3. Restarting after the current tool finishes…",
231
238
  },
232
239
  );
233
240
  assert.deepEqual(
@@ -254,14 +261,19 @@ test("Menu helpers route callback entry states before action handlers", async ()
254
261
  events.push(`answer:${text ?? ""}`);
255
262
  },
256
263
  });
257
- await handleTelegramMenuCallbackEntry("callback-2", "status:model", undefined, {
258
- handleStatusAction: async () => false,
259
- handleThinkingAction: async () => false,
260
- handleModelAction: async () => false,
261
- answerCallbackQuery: async (_id, text) => {
262
- events.push(`answer:${text ?? ""}`);
264
+ await handleTelegramMenuCallbackEntry(
265
+ "callback-2",
266
+ "status:model",
267
+ undefined,
268
+ {
269
+ handleStatusAction: async () => false,
270
+ handleThinkingAction: async () => false,
271
+ handleModelAction: async () => false,
272
+ answerCallbackQuery: async (_id, text) => {
273
+ events.push(`answer:${text ?? ""}`);
274
+ },
263
275
  },
264
- });
276
+ );
265
277
  await handleTelegramMenuCallbackEntry(
266
278
  "callback-3",
267
279
  "status:model",
@@ -296,7 +308,11 @@ test("Menu helpers route callback entry states before action handlers", async ()
296
308
  test("Menu helpers execute model callback actions across update, switch, and restart paths", async () => {
297
309
  const events: string[] = [];
298
310
  const modelA = { provider: "openai", id: "gpt-5", reasoning: true } as const;
299
- const modelB = { provider: "anthropic", id: "claude-3", reasoning: false } as const;
311
+ const modelB = {
312
+ provider: "anthropic",
313
+ id: "claude-3",
314
+ reasoning: false,
315
+ } as const;
300
316
  const state = {
301
317
  chatId: 1,
302
318
  messageId: 2,
@@ -530,8 +546,14 @@ test("Menu helpers build pure render payloads before transport", () => {
530
546
  allModels: [{ model: modelA }],
531
547
  mode: "status" as const,
532
548
  } as unknown as TelegramModelMenuState;
533
- const modelPayload = buildTelegramModelMenuRenderPayload(state, modelA as never);
534
- const thinkingPayload = buildTelegramThinkingMenuRenderPayload(modelA as never, "medium");
549
+ const modelPayload = buildTelegramModelMenuRenderPayload(
550
+ state,
551
+ modelA as never,
552
+ );
553
+ const thinkingPayload = buildTelegramThinkingMenuRenderPayload(
554
+ modelA as never,
555
+ "medium",
556
+ );
535
557
  const statusPayload = buildTelegramStatusMenuRenderPayload(
536
558
  "<b>Status</b>",
537
559
  modelA as never,
@@ -580,7 +602,12 @@ test("Menu helpers update and send interactive menu messages", async () => {
580
602
  },
581
603
  };
582
604
  await updateTelegramModelMenuMessage(state, modelA as never, deps);
583
- await updateTelegramThinkingMenuMessage(state, modelA as never, "medium", deps);
605
+ await updateTelegramThinkingMenuMessage(
606
+ state,
607
+ modelA as never,
608
+ "medium",
609
+ deps,
610
+ );
584
611
  await updateTelegramStatusMessage(
585
612
  state,
586
613
  "<b>Status</b>",
@@ -595,7 +622,11 @@ test("Menu helpers update and send interactive menu messages", async () => {
595
622
  "medium",
596
623
  deps,
597
624
  );
598
- const sentModelId = await sendTelegramModelMenuMessage(state, modelA as never, deps);
625
+ const sentModelId = await sendTelegramModelMenuMessage(
626
+ state,
627
+ modelA as never,
628
+ deps,
629
+ );
599
630
  assert.equal(sentStatusId, 99);
600
631
  assert.equal(sentModelId, 99);
601
632
  assert.equal(events[0], "edit:1:2:html:<b>Choose a model:</b>");
@@ -93,6 +93,79 @@ test("Poll loop initializes lastUpdateId and processes updates", async () => {
93
93
  assert.equal(persistCount, 3);
94
94
  });
95
95
 
96
+ test("Poll loop persists long-poll offsets only after handling updates", async () => {
97
+ const config = { botToken: "123:abc", lastUpdateId: 5 };
98
+ const handled: number[] = [];
99
+ const persisted: number[] = [];
100
+ let calls = 0;
101
+ await runTelegramPollLoop({
102
+ ctx: {} as never,
103
+ signal: new AbortController().signal,
104
+ config,
105
+ deleteWebhook: async () => {},
106
+ getUpdates: async () => {
107
+ calls += 1;
108
+ if (calls === 1) return [{ update_id: 6 }];
109
+ throw new DOMException("stop", "AbortError");
110
+ },
111
+ persistConfig: async () => {
112
+ persisted.push(config.lastUpdateId ?? -1);
113
+ },
114
+ handleUpdate: async (update) => {
115
+ handled.push(update.update_id);
116
+ throw new Error("handler failed");
117
+ },
118
+ onErrorStatus: () => {},
119
+ onStatusReset: () => {},
120
+ sleep: async () => {},
121
+ });
122
+ assert.deepEqual(handled, [6]);
123
+ assert.equal(config.lastUpdateId, 5);
124
+ assert.deepEqual(persisted, []);
125
+ });
126
+
127
+ test("Poll loop skips repeatedly failing updates after the configured threshold", async () => {
128
+ const config = { botToken: "123:abc", lastUpdateId: 5 };
129
+ const persisted: number[] = [];
130
+ const statusMessages: string[] = [];
131
+ let calls = 0;
132
+ await runTelegramPollLoop({
133
+ ctx: {} as never,
134
+ signal: new AbortController().signal,
135
+ config,
136
+ maxUpdateFailures: 2,
137
+ deleteWebhook: async () => {},
138
+ getUpdates: async () => {
139
+ calls += 1;
140
+ if (calls <= 2) return [{ update_id: 6 }];
141
+ throw new DOMException("stop", "AbortError");
142
+ },
143
+ persistConfig: async () => {
144
+ persisted.push(config.lastUpdateId ?? -1);
145
+ },
146
+ handleUpdate: async () => {
147
+ throw new Error("handler failed");
148
+ },
149
+ onErrorStatus: (message) => {
150
+ statusMessages.push(message);
151
+ },
152
+ onStatusReset: () => {
153
+ statusMessages.push("reset");
154
+ },
155
+ sleep: async (ms) => {
156
+ statusMessages.push(`sleep:${ms}`);
157
+ },
158
+ });
159
+ assert.equal(config.lastUpdateId, 6);
160
+ assert.deepEqual(persisted, [6]);
161
+ assert.deepEqual(statusMessages, [
162
+ "handler failed",
163
+ "sleep:3000",
164
+ "reset",
165
+ "skipping Telegram update 6 after 2 failures: handler failed",
166
+ ]);
167
+ });
168
+
96
169
  test("Poll loop reports retryable errors and sleeps before retrying", async () => {
97
170
  const config = { botToken: "123:abc", lastUpdateId: 1 };
98
171
  const statusMessages: string[] = [];