@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/lib/api.ts CHANGED
@@ -3,8 +3,19 @@
3
3
  * Wraps bot API calls, file downloads, and local config reads and writes for the bridge runtime
4
4
  */
5
5
 
6
- import { mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { randomUUID } from "node:crypto";
7
+ import { createWriteStream, openAsBlob } from "node:fs";
8
+ import {
9
+ mkdir,
10
+ readFile,
11
+ readdir,
12
+ stat,
13
+ unlink,
14
+ writeFile,
15
+ } from "node:fs/promises";
7
16
  import { join } from "node:path";
17
+ import { Readable, Transform } from "node:stream";
18
+ import { pipeline } from "node:stream/promises";
8
19
 
9
20
  export interface TelegramConfig {
10
21
  botToken?: string;
@@ -19,17 +30,31 @@ interface TelegramApiResponse<T> {
19
30
  result?: T;
20
31
  description?: string;
21
32
  error_code?: number;
33
+ parameters?: { retry_after?: number };
34
+ }
35
+
36
+ export interface TelegramApiCallOptions {
37
+ signal?: AbortSignal;
38
+ maxAttempts?: number;
39
+ retryBaseDelayMs?: number;
40
+ sleep?: (ms: number) => Promise<void>;
22
41
  }
23
42
 
24
43
  interface TelegramGetFileResult {
25
44
  file_path: string;
45
+ file_size?: number;
46
+ }
47
+
48
+ export interface TelegramFileDownloadOptions {
49
+ signal?: AbortSignal;
50
+ maxFileSizeBytes?: number;
26
51
  }
27
52
 
28
53
  export interface TelegramApiClient {
29
54
  call: <TResponse>(
30
55
  method: string,
31
56
  body: Record<string, unknown>,
32
- options?: { signal?: AbortSignal },
57
+ options?: TelegramApiCallOptions,
33
58
  ) => Promise<TResponse>;
34
59
  callMultipart: <TResponse>(
35
60
  method: string,
@@ -37,12 +62,13 @@ export interface TelegramApiClient {
37
62
  fileField: string,
38
63
  filePath: string,
39
64
  fileName: string,
40
- options?: { signal?: AbortSignal },
65
+ options?: TelegramApiCallOptions,
41
66
  ) => Promise<TResponse>;
42
67
  downloadFile: (
43
68
  fileId: string,
44
69
  suggestedName: string,
45
70
  tempDir: string,
71
+ options?: TelegramFileDownloadOptions,
46
72
  ) => Promise<string>;
47
73
  answerCallbackQuery: (
48
74
  callbackQueryId: string,
@@ -54,6 +80,173 @@ function sanitizeFileName(name: string): string {
54
80
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
55
81
  }
56
82
 
83
+ class TelegramApiHttpError extends Error {
84
+ readonly status: number | undefined;
85
+ readonly retryAfterSeconds: number | undefined;
86
+ constructor(
87
+ message: string,
88
+ status: number | undefined,
89
+ retryAfterSeconds: number | undefined,
90
+ ) {
91
+ super(message);
92
+ this.status = status;
93
+ this.retryAfterSeconds = retryAfterSeconds;
94
+ }
95
+ }
96
+
97
+ function isRetryableTelegramApiError(error: unknown): boolean {
98
+ return (
99
+ error instanceof TelegramApiHttpError &&
100
+ (error.status === 429 ||
101
+ (error.status !== undefined && error.status >= 500))
102
+ );
103
+ }
104
+
105
+ function getTelegramRetryDelayMs(
106
+ error: unknown,
107
+ attempt: number,
108
+ baseDelayMs: number,
109
+ ): number {
110
+ if (
111
+ error instanceof TelegramApiHttpError &&
112
+ error.retryAfterSeconds !== undefined
113
+ ) {
114
+ return Math.max(0, error.retryAfterSeconds * 1000);
115
+ }
116
+ return Math.max(0, baseDelayMs * 2 ** attempt);
117
+ }
118
+
119
+ function sleepTelegramRetry(ms: number): Promise<void> {
120
+ return new Promise((resolve) => setTimeout(resolve, ms));
121
+ }
122
+
123
+ function assertTelegramFileSizeWithinLimit(
124
+ size: number | undefined,
125
+ maxFileSizeBytes: number | undefined,
126
+ ): void {
127
+ if (size === undefined || maxFileSizeBytes === undefined) return;
128
+ if (size <= maxFileSizeBytes) return;
129
+ throw new Error(
130
+ `Telegram file exceeds size limit (${size} bytes > ${maxFileSizeBytes} bytes)`,
131
+ );
132
+ }
133
+
134
+ function createTelegramDownloadLimitTransform(
135
+ maxFileSizeBytes: number | undefined,
136
+ ): Transform {
137
+ let downloadedBytes = 0;
138
+ return new Transform({
139
+ transform(chunk: Buffer, _encoding, callback) {
140
+ downloadedBytes += chunk.byteLength;
141
+ try {
142
+ assertTelegramFileSizeWithinLimit(downloadedBytes, maxFileSizeBytes);
143
+ callback(undefined, chunk);
144
+ } catch (error) {
145
+ callback(error instanceof Error ? error : new Error(String(error)));
146
+ }
147
+ },
148
+ });
149
+ }
150
+
151
+ async function writeTelegramDownloadResponse(
152
+ response: Response,
153
+ targetPath: string,
154
+ maxFileSizeBytes: number | undefined,
155
+ ): Promise<void> {
156
+ if (!response.body) {
157
+ const buffer = Buffer.from(await response.arrayBuffer());
158
+ assertTelegramFileSizeWithinLimit(buffer.byteLength, maxFileSizeBytes);
159
+ await writeFile(targetPath, buffer);
160
+ return;
161
+ }
162
+ await pipeline(
163
+ Readable.fromWeb(
164
+ response.body as unknown as Parameters<typeof Readable.fromWeb>[0],
165
+ ),
166
+ createTelegramDownloadLimitTransform(maxFileSizeBytes),
167
+ createWriteStream(targetPath),
168
+ );
169
+ }
170
+
171
+ async function removeTelegramPartialDownload(path: string): Promise<void> {
172
+ try {
173
+ await unlink(path);
174
+ } catch {
175
+ // ignore
176
+ }
177
+ }
178
+
179
+ async function parseTelegramApiResponse<TResponse>(
180
+ response: Response,
181
+ method: string,
182
+ ): Promise<TelegramApiResponse<TResponse>> {
183
+ let data: TelegramApiResponse<TResponse> | undefined;
184
+ try {
185
+ if (typeof response.text === "function") {
186
+ const text = await response.text();
187
+ data = text
188
+ ? (JSON.parse(text) as TelegramApiResponse<TResponse>)
189
+ : undefined;
190
+ } else {
191
+ data = (await response.json()) as TelegramApiResponse<TResponse>;
192
+ }
193
+ } catch {
194
+ data = undefined;
195
+ }
196
+ if (response.ok === false) {
197
+ const status = `HTTP ${response.status}`;
198
+ const description = data?.description ? `: ${data.description}` : "";
199
+ const retryAfterHeader = response.headers?.get("retry-after");
200
+ const retryAfterSeconds =
201
+ data?.parameters?.retry_after ??
202
+ (retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined);
203
+ throw new TelegramApiHttpError(
204
+ `Telegram API ${method} failed: ${status}${description}`,
205
+ response.status,
206
+ Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
207
+ );
208
+ }
209
+ return (
210
+ data ?? {
211
+ ok: false,
212
+ description: `Telegram API ${method} returned invalid JSON`,
213
+ }
214
+ );
215
+ }
216
+
217
+ function unwrapTelegramApiResult<TResponse>(
218
+ method: string,
219
+ data: TelegramApiResponse<TResponse>,
220
+ ): TResponse {
221
+ if (!data.ok || data.result === undefined) {
222
+ throw new Error(data.description || `Telegram API ${method} failed`);
223
+ }
224
+ return data.result;
225
+ }
226
+
227
+ async function callTelegramWithRetry<TResponse>(
228
+ method: string,
229
+ request: () => Promise<Response>,
230
+ options: TelegramApiCallOptions | undefined,
231
+ ): Promise<TResponse> {
232
+ const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
233
+ const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
234
+ const sleep = options?.sleep ?? sleepTelegramRetry;
235
+ for (let attempt = 0; ; attempt += 1) {
236
+ try {
237
+ return unwrapTelegramApiResult(
238
+ method,
239
+ await parseTelegramApiResponse<TResponse>(await request(), method),
240
+ );
241
+ } catch (error) {
242
+ if (attempt >= maxAttempts - 1 || !isRetryableTelegramApiError(error)) {
243
+ throw error;
244
+ }
245
+ await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
246
+ }
247
+ }
248
+ }
249
+
57
250
  export async function readTelegramConfig(
58
251
  configPath: string,
59
252
  ): Promise<TelegramConfig> {
@@ -78,29 +271,53 @@ export async function writeTelegramConfig(
78
271
  );
79
272
  }
80
273
 
274
+ export async function cleanupTelegramTempFiles(
275
+ tempDir: string,
276
+ maxAgeMs: number,
277
+ now = Date.now(),
278
+ ): Promise<number> {
279
+ let removedCount = 0;
280
+ let entries: Array<{ isFile(): boolean; name: string }>;
281
+ try {
282
+ entries = await readdir(tempDir, { withFileTypes: true });
283
+ } catch {
284
+ return 0;
285
+ }
286
+ for (const entry of entries) {
287
+ if (!entry.isFile()) continue;
288
+ const path = join(tempDir, entry.name);
289
+ try {
290
+ const stats = await stat(path);
291
+ if (now - stats.mtimeMs <= maxAgeMs) continue;
292
+ await unlink(path);
293
+ removedCount += 1;
294
+ } catch {
295
+ // ignore
296
+ }
297
+ }
298
+ return removedCount;
299
+ }
300
+
81
301
  export async function callTelegram<TResponse>(
82
302
  botToken: string | undefined,
83
303
  method: string,
84
304
  body: Record<string, unknown>,
85
- options?: { signal?: AbortSignal },
305
+ options?: TelegramApiCallOptions,
86
306
  ): Promise<TResponse> {
87
307
  if (!botToken) {
88
308
  throw new Error("Telegram bot token is not configured");
89
309
  }
90
- const response = await fetch(
91
- `https://api.telegram.org/bot${botToken}/${method}`,
92
- {
93
- method: "POST",
94
- headers: { "content-type": "application/json" },
95
- body: JSON.stringify(body),
96
- signal: options?.signal,
97
- },
310
+ return callTelegramWithRetry(
311
+ method,
312
+ async () =>
313
+ fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
314
+ method: "POST",
315
+ headers: { "content-type": "application/json" },
316
+ body: JSON.stringify(body),
317
+ signal: options?.signal,
318
+ }),
319
+ options,
98
320
  );
99
- const data = (await response.json()) as TelegramApiResponse<TResponse>;
100
- if (!data.ok || data.result === undefined) {
101
- throw new Error(data.description || `Telegram API ${method} failed`);
102
- }
103
- return data.result;
104
321
  }
105
322
 
106
323
  export async function callTelegramMultipart<TResponse>(
@@ -110,30 +327,28 @@ export async function callTelegramMultipart<TResponse>(
110
327
  fileField: string,
111
328
  filePath: string,
112
329
  fileName: string,
113
- options?: { signal?: AbortSignal },
330
+ options?: TelegramApiCallOptions,
114
331
  ): Promise<TResponse> {
115
332
  if (!botToken) {
116
333
  throw new Error("Telegram bot token is not configured");
117
334
  }
118
- const form = new FormData();
119
- for (const [key, value] of Object.entries(fields)) {
120
- form.set(key, value);
121
- }
122
- const buffer = await readFile(filePath);
123
- form.set(fileField, new Blob([buffer]), fileName);
124
- const response = await fetch(
125
- `https://api.telegram.org/bot${botToken}/${method}`,
126
- {
127
- method: "POST",
128
- body: form,
129
- signal: options?.signal,
335
+ const fileBlob = await openAsBlob(filePath);
336
+ return callTelegramWithRetry(
337
+ method,
338
+ async () => {
339
+ const form = new FormData();
340
+ for (const [key, value] of Object.entries(fields)) {
341
+ form.set(key, value);
342
+ }
343
+ form.set(fileField, fileBlob, fileName);
344
+ return fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
345
+ method: "POST",
346
+ body: form,
347
+ signal: options?.signal,
348
+ });
130
349
  },
350
+ options,
131
351
  );
132
- const data = (await response.json()) as TelegramApiResponse<TResponse>;
133
- if (!data.ok || data.result === undefined) {
134
- throw new Error(data.description || `Telegram API ${method} failed`);
135
- }
136
- return data.result;
137
352
  }
138
353
 
139
354
  export async function downloadTelegramFile(
@@ -141,26 +356,45 @@ export async function downloadTelegramFile(
141
356
  fileId: string,
142
357
  suggestedName: string,
143
358
  tempDir: string,
359
+ options?: TelegramFileDownloadOptions,
144
360
  ): Promise<string> {
145
361
  if (!botToken) {
146
362
  throw new Error("Telegram bot token is not configured");
147
363
  }
148
- const file = await callTelegram<TelegramGetFileResult>(botToken, "getFile", {
149
- file_id: fileId,
150
- });
364
+ const file = await callTelegram<TelegramGetFileResult>(
365
+ botToken,
366
+ "getFile",
367
+ { file_id: fileId },
368
+ { signal: options?.signal },
369
+ );
370
+ assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
151
371
  await mkdir(tempDir, { recursive: true });
152
372
  const targetPath = join(
153
373
  tempDir,
154
- `${Date.now()}-${sanitizeFileName(suggestedName)}`,
374
+ `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
155
375
  );
156
376
  const response = await fetch(
157
377
  `https://api.telegram.org/file/bot${botToken}/${file.file_path}`,
378
+ { signal: options?.signal },
158
379
  );
159
380
  if (!response.ok) {
160
381
  throw new Error(`Failed to download Telegram file: ${response.status}`);
161
382
  }
162
- const arrayBuffer = await response.arrayBuffer();
163
- await writeFile(targetPath, Buffer.from(arrayBuffer));
383
+ const contentLength = response.headers?.get("content-length");
384
+ assertTelegramFileSizeWithinLimit(
385
+ contentLength ? Number.parseInt(contentLength, 10) : undefined,
386
+ options?.maxFileSizeBytes,
387
+ );
388
+ try {
389
+ await writeTelegramDownloadResponse(
390
+ response,
391
+ targetPath,
392
+ options?.maxFileSizeBytes,
393
+ );
394
+ } catch (error) {
395
+ await removeTelegramPartialDownload(targetPath);
396
+ throw error;
397
+ }
164
398
  return targetPath;
165
399
  }
166
400
 
@@ -207,12 +441,13 @@ export function createTelegramApiClient(
207
441
  options,
208
442
  );
209
443
  },
210
- downloadFile: async (fileId, suggestedName, tempDir) => {
444
+ downloadFile: async (fileId, suggestedName, tempDir, options) => {
211
445
  return downloadTelegramFile(
212
446
  getBotToken(),
213
447
  fileId,
214
448
  suggestedName,
215
449
  tempDir,
450
+ options,
216
451
  );
217
452
  },
218
453
  answerCallbackQuery: async (callbackQueryId, text) => {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Telegram command parsing helpers
3
+ * Owns slash-command normalization so command routing stays separate from transport update handling
4
+ */
5
+
6
+ export interface ParsedTelegramCommand {
7
+ name: string;
8
+ args: string;
9
+ }
10
+
11
+ export type TelegramCommandAction =
12
+ | { kind: "ignore" }
13
+ | { kind: "stop" }
14
+ | { kind: "compact" }
15
+ | { kind: "status" }
16
+ | { kind: "model" }
17
+ | { kind: "help"; commandName: "help" | "start" };
18
+
19
+ export interface TelegramCommandActionDeps<TMessage, TContext> {
20
+ handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
21
+ handleCompact: (message: TMessage, ctx: TContext) => Promise<void>;
22
+ handleStatus: (message: TMessage, ctx: TContext) => Promise<void>;
23
+ handleModel: (message: TMessage, ctx: TContext) => Promise<void>;
24
+ handleHelp: (
25
+ message: TMessage,
26
+ commandName: "help" | "start",
27
+ ctx: TContext,
28
+ ) => Promise<void>;
29
+ }
30
+
31
+ export function parseTelegramCommand(
32
+ text: string,
33
+ ): ParsedTelegramCommand | undefined {
34
+ const trimmed = text.trim();
35
+ if (!trimmed.startsWith("/")) return undefined;
36
+ const [head, ...tail] = trimmed.split(/\s+/);
37
+ const name = head.slice(1).split("@")[0]?.toLowerCase();
38
+ if (!name) return undefined;
39
+ return { name, args: tail.join(" ").trim() };
40
+ }
41
+
42
+ export function buildTelegramCommandAction(
43
+ commandName: string | undefined,
44
+ ): TelegramCommandAction {
45
+ switch (commandName) {
46
+ case "stop":
47
+ return { kind: "stop" };
48
+ case "compact":
49
+ return { kind: "compact" };
50
+ case "status":
51
+ return { kind: "status" };
52
+ case "model":
53
+ return { kind: "model" };
54
+ case "help":
55
+ case "start":
56
+ return { kind: "help", commandName };
57
+ default:
58
+ return { kind: "ignore" };
59
+ }
60
+ }
61
+
62
+ export async function executeTelegramCommandAction<TMessage, TContext>(
63
+ action: TelegramCommandAction,
64
+ message: TMessage,
65
+ ctx: TContext,
66
+ deps: TelegramCommandActionDeps<TMessage, TContext>,
67
+ ): Promise<boolean> {
68
+ switch (action.kind) {
69
+ case "ignore":
70
+ return false;
71
+ case "stop":
72
+ await deps.handleStop(message, ctx);
73
+ return true;
74
+ case "compact":
75
+ await deps.handleCompact(message, ctx);
76
+ return true;
77
+ case "status":
78
+ await deps.handleStatus(message, ctx);
79
+ return true;
80
+ case "model":
81
+ await deps.handleModel(message, ctx);
82
+ return true;
83
+ case "help":
84
+ await deps.handleHelp(message, action.commandName, ctx);
85
+ return true;
86
+ }
87
+ }
package/lib/media.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Telegram media and text extraction helpers
3
- * Normalizes inbound Telegram messages into reusable file, text, id, and history metadata
3
+ * Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
4
4
  */
5
5
 
6
6
  export interface TelegramPhotoSizeLike {
@@ -45,6 +45,7 @@ export interface TelegramMessageLike {
45
45
  message_id: number;
46
46
  text?: string;
47
47
  caption?: string;
48
+ media_group_id?: string;
48
49
  photo?: TelegramPhotoSizeLike[];
49
50
  document?: TelegramDocumentLike;
50
51
  video?: TelegramVideoLike;
@@ -54,6 +55,17 @@ export interface TelegramMessageLike {
54
55
  sticker?: TelegramStickerLike;
55
56
  }
56
57
 
58
+ export interface TelegramMediaGroupMessageLike {
59
+ message_id: number;
60
+ chat: { id: number };
61
+ media_group_id?: string;
62
+ }
63
+
64
+ export interface TelegramMediaGroupState<TMessage> {
65
+ messages: TMessage[];
66
+ flushTimer?: ReturnType<typeof setTimeout>;
67
+ }
68
+
57
69
  export interface TelegramFileInfo {
58
70
  file_id: string;
59
71
  fileName: string;
@@ -122,6 +134,63 @@ export function collectTelegramMessageIds(
122
134
  return [...new Set(messages.map((message) => message.message_id))];
123
135
  }
124
136
 
137
+ export function getTelegramMediaGroupKey(
138
+ message: TelegramMediaGroupMessageLike,
139
+ ): string | undefined {
140
+ if (!message.media_group_id) return undefined;
141
+ return `${message.chat.id}:${message.media_group_id}`;
142
+ }
143
+
144
+ export function removePendingTelegramMediaGroupMessages<
145
+ TMessage extends TelegramMediaGroupMessageLike,
146
+ >(
147
+ groups: Map<string, TelegramMediaGroupState<TMessage>>,
148
+ messageIds: number[],
149
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
150
+ ): number {
151
+ if (messageIds.length === 0 || groups.size === 0) return 0;
152
+ const deletedMessageIds = new Set(messageIds);
153
+ let removedGroups = 0;
154
+ for (const [key, state] of groups.entries()) {
155
+ if (
156
+ !state.messages.some((message) =>
157
+ deletedMessageIds.has(message.message_id),
158
+ )
159
+ ) {
160
+ continue;
161
+ }
162
+ if (state.flushTimer) clearTimer(state.flushTimer);
163
+ groups.delete(key);
164
+ removedGroups += 1;
165
+ }
166
+ return removedGroups;
167
+ }
168
+
169
+ export function queueTelegramMediaGroupMessage<
170
+ TMessage extends TelegramMediaGroupMessageLike,
171
+ >(options: {
172
+ message: TMessage;
173
+ groups: Map<string, TelegramMediaGroupState<TMessage>>;
174
+ debounceMs: number;
175
+ setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
176
+ clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
177
+ dispatchMessages: (messages: TMessage[]) => void;
178
+ }): boolean {
179
+ const key = getTelegramMediaGroupKey(options.message);
180
+ if (!key) return false;
181
+ const existing = options.groups.get(key) ?? { messages: [] };
182
+ existing.messages.push(options.message);
183
+ if (existing.flushTimer) options.clearTimer(existing.flushTimer);
184
+ existing.flushTimer = options.setTimer(() => {
185
+ const state = options.groups.get(key);
186
+ options.groups.delete(key);
187
+ if (!state) return;
188
+ options.dispatchMessages(state.messages);
189
+ }, options.debounceMs);
190
+ options.groups.set(key, existing);
191
+ return true;
192
+ }
193
+
125
194
  export function formatTelegramHistoryText(
126
195
  rawText: string,
127
196
  files: DownloadedTelegramFileLike[],
package/lib/polling.ts CHANGED
@@ -76,6 +76,11 @@ export interface TelegramPollLoopDeps<TUpdate extends TelegramUpdateLike> {
76
76
  onErrorStatus: (message: string) => void;
77
77
  onStatusReset: () => void;
78
78
  sleep: (ms: number) => Promise<void>;
79
+ maxUpdateFailures?: number;
80
+ }
81
+
82
+ function getTelegramPollingErrorMessage(error: unknown): string {
83
+ return error instanceof Error ? error.message : String(error);
79
84
  }
80
85
 
81
86
  export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
@@ -102,6 +107,8 @@ export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
102
107
  // ignore
103
108
  }
104
109
  }
110
+ const maxUpdateFailures = Math.max(1, deps.maxUpdateFailures ?? 3);
111
+ const updateFailures = new Map<number, number>();
105
112
  while (!deps.signal.aborted) {
106
113
  try {
107
114
  const updates = await deps.getUpdates(
@@ -109,14 +116,27 @@ export async function runTelegramPollLoop<TUpdate extends TelegramUpdateLike>(
109
116
  deps.signal,
110
117
  );
111
118
  for (const update of updates) {
112
- deps.config.lastUpdateId = update.update_id;
113
- await deps.persistConfig();
114
- await deps.handleUpdate(update, deps.ctx);
119
+ try {
120
+ await deps.handleUpdate(update, deps.ctx);
121
+ deps.config.lastUpdateId = update.update_id;
122
+ updateFailures.delete(update.update_id);
123
+ await deps.persistConfig();
124
+ } catch (error) {
125
+ const failureCount = (updateFailures.get(update.update_id) ?? 0) + 1;
126
+ updateFailures.set(update.update_id, failureCount);
127
+ if (failureCount < maxUpdateFailures) throw error;
128
+ const message = getTelegramPollingErrorMessage(error);
129
+ deps.onErrorStatus(
130
+ `skipping Telegram update ${update.update_id} after ${failureCount} failures: ${message}`,
131
+ );
132
+ deps.config.lastUpdateId = update.update_id;
133
+ updateFailures.delete(update.update_id);
134
+ await deps.persistConfig();
135
+ }
115
136
  }
116
137
  } catch (error) {
117
138
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
118
- const message = error instanceof Error ? error.message : String(error);
119
- deps.onErrorStatus(message);
139
+ deps.onErrorStatus(getTelegramPollingErrorMessage(error));
120
140
  await deps.sleep(3000);
121
141
  deps.onStatusReset();
122
142
  }