@llblab/pi-telegram 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/api.ts CHANGED
@@ -1,35 +1,211 @@
1
1
  /**
2
- * Telegram API and config persistence helpers
3
- * Wraps bot API calls, file downloads, and local config reads and writes for the bridge runtime
2
+ * Telegram API transport helpers
3
+ * Wraps bot API calls, file downloads, runtime transport binding, and Telegram temp-file cleanup
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 { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
7
10
  import { join } from "node:path";
11
+ import { Readable, Transform } from "node:stream";
12
+ import { pipeline } from "node:stream/promises";
8
13
 
9
- export interface TelegramConfig {
10
- botToken?: string;
11
- botUsername?: string;
12
- botId?: number;
13
- allowedUserId?: number;
14
- lastUpdateId?: number;
14
+ export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
15
+
16
+ export function getTelegramInboundFileByteLimitFromEnv(
17
+ env: NodeJS.ProcessEnv,
18
+ names: string[],
19
+ defaultValue = TELEGRAM_FILE_MAX_BYTES,
20
+ ): number {
21
+ for (const name of names) {
22
+ const rawValue = env[name]?.trim();
23
+ if (!rawValue) continue;
24
+ const parsed = Number(rawValue);
25
+ if (Number.isSafeInteger(parsed) && parsed > 0) return parsed;
26
+ }
27
+ return defaultValue;
28
+ }
29
+
30
+ const TEMP_DIR = join(homedir(), ".pi", "agent", "tmp", "telegram");
31
+ const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
32
+ const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
33
+ process.env,
34
+ ["PI_TELEGRAM_INBOUND_FILE_MAX_BYTES", "TELEGRAM_MAX_FILE_SIZE_BYTES"],
35
+ TELEGRAM_FILE_MAX_BYTES,
36
+ );
37
+
38
+ export interface TelegramUser {
39
+ id: number;
40
+ is_bot: boolean;
41
+ first_name: string;
42
+ username?: string;
43
+ }
44
+
45
+ export interface TelegramChat {
46
+ id: number;
47
+ type: string;
48
+ }
49
+
50
+ export interface TelegramPhotoSize {
51
+ file_id: string;
52
+ file_size?: number;
53
+ }
54
+
55
+ export interface TelegramDocument {
56
+ file_id: string;
57
+ file_name?: string;
58
+ mime_type?: string;
59
+ file_size?: number;
60
+ }
61
+
62
+ export interface TelegramVideo {
63
+ file_id: string;
64
+ file_name?: string;
65
+ mime_type?: string;
66
+ file_size?: number;
67
+ }
68
+
69
+ export interface TelegramAudio {
70
+ file_id: string;
71
+ file_name?: string;
72
+ mime_type?: string;
73
+ file_size?: number;
74
+ }
75
+
76
+ export interface TelegramVoice {
77
+ file_id: string;
78
+ mime_type?: string;
79
+ file_size?: number;
80
+ }
81
+
82
+ export interface TelegramAnimation {
83
+ file_id: string;
84
+ file_name?: string;
85
+ mime_type?: string;
86
+ file_size?: number;
87
+ }
88
+
89
+ export interface TelegramSticker {
90
+ file_id: string;
91
+ emoji?: string;
92
+ }
93
+
94
+ export interface TelegramMessage {
95
+ message_id: number;
96
+ chat: TelegramChat;
97
+ from?: TelegramUser;
98
+ text?: string;
99
+ caption?: string;
100
+ media_group_id?: string;
101
+ photo?: TelegramPhotoSize[];
102
+ document?: TelegramDocument;
103
+ video?: TelegramVideo;
104
+ audio?: TelegramAudio;
105
+ voice?: TelegramVoice;
106
+ animation?: TelegramAnimation;
107
+ sticker?: TelegramSticker;
15
108
  }
16
109
 
110
+ export interface TelegramCallbackQuery {
111
+ id: string;
112
+ from: TelegramUser;
113
+ message?: TelegramMessage;
114
+ data?: string;
115
+ }
116
+
117
+ export interface TelegramReactionTypeEmoji {
118
+ type: "emoji";
119
+ emoji: string;
120
+ }
121
+
122
+ export interface TelegramReactionTypeCustomEmoji {
123
+ type: "custom_emoji";
124
+ custom_emoji_id: string;
125
+ }
126
+
127
+ export interface TelegramReactionTypePaid {
128
+ type: "paid";
129
+ }
130
+
131
+ export type TelegramReactionType =
132
+ | TelegramReactionTypeEmoji
133
+ | TelegramReactionTypeCustomEmoji
134
+ | TelegramReactionTypePaid;
135
+
136
+ export interface TelegramMessageReactionUpdated {
137
+ chat: TelegramChat;
138
+ message_id: number;
139
+ user?: TelegramUser;
140
+ actor_chat?: TelegramChat;
141
+ old_reaction: TelegramReactionType[];
142
+ new_reaction: TelegramReactionType[];
143
+ date: number;
144
+ }
145
+
146
+ export interface TelegramUpdate {
147
+ update_id: number;
148
+ message?: TelegramMessage;
149
+ edited_message?: TelegramMessage;
150
+ callback_query?: TelegramCallbackQuery;
151
+ message_reaction?: TelegramMessageReactionUpdated;
152
+ deleted_business_messages?: { message_ids?: unknown };
153
+ }
154
+
155
+ export interface TelegramSentMessage {
156
+ message_id: number;
157
+ }
158
+
159
+ export interface TelegramReplyParameters {
160
+ message_id: number;
161
+ allow_sending_without_reply: true;
162
+ }
163
+
164
+ export type TelegramSendMessageBody = Record<string, unknown> & {
165
+ chat_id: number;
166
+ text: string;
167
+ parse_mode?: "HTML";
168
+ reply_markup?: unknown;
169
+ reply_parameters?: TelegramReplyParameters;
170
+ };
171
+
172
+ export type TelegramEditMessageTextBody = Record<string, unknown> & {
173
+ chat_id: number;
174
+ message_id: number;
175
+ text: string;
176
+ parse_mode?: "HTML";
177
+ };
178
+
17
179
  interface TelegramApiResponse<T> {
18
180
  ok: boolean;
19
181
  result?: T;
20
182
  description?: string;
21
183
  error_code?: number;
184
+ parameters?: { retry_after?: number };
185
+ }
186
+
187
+ export interface TelegramApiCallOptions {
188
+ signal?: AbortSignal;
189
+ maxAttempts?: number;
190
+ retryBaseDelayMs?: number;
191
+ sleep?: (ms: number) => Promise<void>;
22
192
  }
23
193
 
24
194
  interface TelegramGetFileResult {
25
195
  file_path: string;
196
+ file_size?: number;
197
+ }
198
+
199
+ export interface TelegramFileDownloadOptions {
200
+ signal?: AbortSignal;
201
+ maxFileSizeBytes?: number;
26
202
  }
27
203
 
28
204
  export interface TelegramApiClient {
29
205
  call: <TResponse>(
30
206
  method: string,
31
207
  body: Record<string, unknown>,
32
- options?: { signal?: AbortSignal },
208
+ options?: TelegramApiCallOptions,
33
209
  ) => Promise<TResponse>;
34
210
  callMultipart: <TResponse>(
35
211
  method: string,
@@ -37,12 +213,13 @@ export interface TelegramApiClient {
37
213
  fileField: string,
38
214
  filePath: string,
39
215
  fileName: string,
40
- options?: { signal?: AbortSignal },
216
+ options?: TelegramApiCallOptions,
41
217
  ) => Promise<TResponse>;
42
218
  downloadFile: (
43
219
  fileId: string,
44
220
  suggestedName: string,
45
221
  tempDir: string,
222
+ options?: TelegramFileDownloadOptions,
46
223
  ) => Promise<string>;
47
224
  answerCallbackQuery: (
48
225
  callbackQueryId: string,
@@ -50,59 +227,311 @@ export interface TelegramApiClient {
50
227
  ) => Promise<void>;
51
228
  }
52
229
 
230
+ export interface TelegramBridgeApiRuntimeDeps {
231
+ client: TelegramApiClient;
232
+ tempDir: string;
233
+ maxFileSizeBytes: number;
234
+ tempFileMaxAgeMs: number;
235
+ recordRuntimeEvent: (
236
+ kind: "api" | "multipart" | "download",
237
+ error: unknown,
238
+ details?: Record<string, unknown>,
239
+ ) => void;
240
+ }
241
+
242
+ export interface TelegramBridgeApiRuntime {
243
+ call: <TResponse>(
244
+ method: string,
245
+ body: Record<string, unknown>,
246
+ options?: TelegramApiCallOptions,
247
+ ) => Promise<TResponse>;
248
+ callMultipart: <TResponse>(
249
+ method: string,
250
+ fields: Record<string, string>,
251
+ fileField: string,
252
+ filePath: string,
253
+ fileName: string,
254
+ options?: TelegramApiCallOptions,
255
+ ) => Promise<TResponse>;
256
+ downloadFile: (fileId: string, suggestedName: string) => Promise<string>;
257
+ deleteWebhook: (signal?: AbortSignal) => Promise<boolean>;
258
+ getUpdates: (
259
+ body: Record<string, unknown>,
260
+ signal?: AbortSignal,
261
+ ) => Promise<TelegramUpdate[]>;
262
+ setMyCommands: (
263
+ commands: readonly { command: string; description: string }[],
264
+ ) => Promise<boolean>;
265
+ sendChatAction: (chatId: number, action: "typing") => Promise<boolean>;
266
+ sendTypingAction: (chatId: number) => Promise<unknown>;
267
+ sendMessageDraft: (
268
+ chatId: number,
269
+ draftId: number,
270
+ text: string,
271
+ ) => Promise<boolean>;
272
+ sendMessage: (body: TelegramSendMessageBody) => Promise<TelegramSentMessage>;
273
+ editMessageText: (
274
+ body: TelegramEditMessageTextBody,
275
+ ) => Promise<"edited" | "unchanged">;
276
+ answerCallbackQuery: (
277
+ callbackQueryId: string,
278
+ text?: string,
279
+ ) => Promise<void>;
280
+ prepareTempDir: () => Promise<number>;
281
+ }
282
+
53
283
  function sanitizeFileName(name: string): string {
54
284
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
55
285
  }
56
286
 
57
- export async function readTelegramConfig(
58
- configPath: string,
59
- ): Promise<TelegramConfig> {
60
- try {
61
- const content = await readFile(configPath, "utf8");
62
- return JSON.parse(content) as TelegramConfig;
63
- } catch {
64
- return {};
287
+ class TelegramApiHttpError extends Error {
288
+ readonly status: number | undefined;
289
+ readonly retryAfterSeconds: number | undefined;
290
+ constructor(
291
+ message: string,
292
+ status: number | undefined,
293
+ retryAfterSeconds: number | undefined,
294
+ ) {
295
+ super(message);
296
+ this.status = status;
297
+ this.retryAfterSeconds = retryAfterSeconds;
65
298
  }
66
299
  }
67
300
 
68
- export async function writeTelegramConfig(
69
- agentDir: string,
70
- configPath: string,
71
- config: TelegramConfig,
301
+ export function isTelegramMessageNotModifiedError(error: unknown): boolean {
302
+ return (
303
+ error instanceof Error && error.message.includes("message is not modified")
304
+ );
305
+ }
306
+
307
+ function isRetryableTelegramApiError(error: unknown): boolean {
308
+ return (
309
+ error instanceof TelegramApiHttpError &&
310
+ (error.status === 429 ||
311
+ (error.status !== undefined && error.status >= 500))
312
+ );
313
+ }
314
+
315
+ function getTelegramRetryDelayMs(
316
+ error: unknown,
317
+ attempt: number,
318
+ baseDelayMs: number,
319
+ ): number {
320
+ if (
321
+ error instanceof TelegramApiHttpError &&
322
+ error.retryAfterSeconds !== undefined
323
+ ) {
324
+ return Math.max(0, error.retryAfterSeconds * 1000);
325
+ }
326
+ return Math.max(0, baseDelayMs * 2 ** attempt);
327
+ }
328
+
329
+ function sleepTelegramRetry(ms: number): Promise<void> {
330
+ return new Promise((resolve) => setTimeout(resolve, ms));
331
+ }
332
+
333
+ function assertTelegramFileSizeWithinLimit(
334
+ size: number | undefined,
335
+ maxFileSizeBytes: number | undefined,
336
+ ): void {
337
+ if (size === undefined || maxFileSizeBytes === undefined) return;
338
+ if (size <= maxFileSizeBytes) return;
339
+ throw new Error(
340
+ `Telegram file exceeds size limit (${size} bytes > ${maxFileSizeBytes} bytes)`,
341
+ );
342
+ }
343
+
344
+ function createTelegramDownloadLimitTransform(
345
+ maxFileSizeBytes: number | undefined,
346
+ ): Transform {
347
+ let downloadedBytes = 0;
348
+ return new Transform({
349
+ transform(chunk: Buffer, _encoding, callback) {
350
+ downloadedBytes += chunk.byteLength;
351
+ try {
352
+ assertTelegramFileSizeWithinLimit(downloadedBytes, maxFileSizeBytes);
353
+ callback(undefined, chunk);
354
+ } catch (error) {
355
+ callback(error instanceof Error ? error : new Error(String(error)));
356
+ }
357
+ },
358
+ });
359
+ }
360
+
361
+ async function writeTelegramDownloadResponse(
362
+ response: Response,
363
+ targetPath: string,
364
+ maxFileSizeBytes: number | undefined,
72
365
  ): Promise<void> {
73
- await mkdir(agentDir, { recursive: true });
74
- await writeFile(
75
- configPath,
76
- JSON.stringify(config, null, "\t") + "\n",
77
- "utf8",
366
+ if (!response.body) {
367
+ const buffer = Buffer.from(await response.arrayBuffer());
368
+ assertTelegramFileSizeWithinLimit(buffer.byteLength, maxFileSizeBytes);
369
+ await writeFile(targetPath, buffer);
370
+ return;
371
+ }
372
+ await pipeline(
373
+ Readable.from(response.body, { objectMode: false }),
374
+ createTelegramDownloadLimitTransform(maxFileSizeBytes),
375
+ createWriteStream(targetPath),
78
376
  );
79
377
  }
80
378
 
81
- export async function callTelegram<TResponse>(
82
- botToken: string | undefined,
379
+ async function removeTelegramPartialDownload(path: string): Promise<void> {
380
+ try {
381
+ await unlink(path);
382
+ } catch {
383
+ // ignore
384
+ }
385
+ }
386
+
387
+ async function parseTelegramApiResponse<TResponse>(
388
+ response: Response,
83
389
  method: string,
84
- body: Record<string, unknown>,
85
- options?: { signal?: AbortSignal },
86
- ): Promise<TResponse> {
87
- if (!botToken) {
88
- throw new Error("Telegram bot token is not configured");
390
+ ): Promise<TelegramApiResponse<TResponse>> {
391
+ let data: TelegramApiResponse<TResponse> | undefined;
392
+ try {
393
+ if (typeof response.text === "function") {
394
+ const text = await response.text();
395
+ data = text
396
+ ? (JSON.parse(text) as TelegramApiResponse<TResponse>)
397
+ : undefined;
398
+ } else {
399
+ data = (await response.json()) as TelegramApiResponse<TResponse>;
400
+ }
401
+ } catch {
402
+ data = undefined;
89
403
  }
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
- },
404
+ if (response.ok === false) {
405
+ const status = `HTTP ${response.status}`;
406
+ const description = data?.description ? `: ${data.description}` : "";
407
+ const retryAfterHeader = response.headers?.get("retry-after");
408
+ const retryAfterSeconds =
409
+ data?.parameters?.retry_after ??
410
+ (retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined);
411
+ throw new TelegramApiHttpError(
412
+ `Telegram API ${method} failed: ${status}${description}`,
413
+ response.status,
414
+ Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
415
+ );
416
+ }
417
+ return (
418
+ data ?? {
419
+ ok: false,
420
+ description: `Telegram API ${method} returned invalid JSON`,
421
+ }
98
422
  );
99
- const data = (await response.json()) as TelegramApiResponse<TResponse>;
423
+ }
424
+
425
+ function unwrapTelegramApiResult<TResponse>(
426
+ method: string,
427
+ data: TelegramApiResponse<TResponse>,
428
+ ): TResponse {
100
429
  if (!data.ok || data.result === undefined) {
101
430
  throw new Error(data.description || `Telegram API ${method} failed`);
102
431
  }
103
432
  return data.result;
104
433
  }
105
434
 
435
+ async function callTelegramWithRetry<TResponse>(
436
+ method: string,
437
+ request: () => Promise<Response>,
438
+ options: TelegramApiCallOptions | undefined,
439
+ ): Promise<TResponse> {
440
+ const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
441
+ const retryBaseDelayMs = options?.retryBaseDelayMs ?? 500;
442
+ const sleep = options?.sleep ?? sleepTelegramRetry;
443
+ for (let attempt = 0; ; attempt += 1) {
444
+ try {
445
+ return unwrapTelegramApiResult(
446
+ method,
447
+ await parseTelegramApiResponse<TResponse>(await request(), method),
448
+ );
449
+ } catch (error) {
450
+ if (attempt >= maxAttempts - 1 || !isRetryableTelegramApiError(error)) {
451
+ throw error;
452
+ }
453
+ await sleep(getTelegramRetryDelayMs(error, attempt, retryBaseDelayMs));
454
+ }
455
+ }
456
+ }
457
+
458
+ export async function cleanupTelegramTempFiles(
459
+ tempDir: string,
460
+ maxAgeMs: number,
461
+ now = Date.now(),
462
+ ): Promise<number> {
463
+ let removedCount = 0;
464
+ let entries: Array<{ isFile(): boolean; name: string }>;
465
+ try {
466
+ entries = await readdir(tempDir, { withFileTypes: true });
467
+ } catch {
468
+ return 0;
469
+ }
470
+ for (const entry of entries) {
471
+ if (!entry.isFile()) continue;
472
+ const path = join(tempDir, entry.name);
473
+ try {
474
+ const stats = await stat(path);
475
+ if (now - stats.mtimeMs <= maxAgeMs) continue;
476
+ await unlink(path);
477
+ removedCount += 1;
478
+ } catch {
479
+ // ignore
480
+ }
481
+ }
482
+ return removedCount;
483
+ }
484
+
485
+ export async function prepareTelegramTempDir(
486
+ tempDir: string,
487
+ maxAgeMs: number,
488
+ ): Promise<number> {
489
+ await mkdir(tempDir, { recursive: true });
490
+ return cleanupTelegramTempFiles(tempDir, maxAgeMs);
491
+ }
492
+
493
+ function assertTelegramBotTokenConfigured(
494
+ botToken: string | undefined,
495
+ ): string {
496
+ if (!botToken) throw new Error("Telegram bot token is not configured");
497
+ return botToken;
498
+ }
499
+
500
+ export async function callTelegram<TResponse>(
501
+ botToken: string | undefined,
502
+ method: string,
503
+ body: Record<string, unknown>,
504
+ options?: TelegramApiCallOptions,
505
+ ): Promise<TResponse> {
506
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
507
+ return callTelegramWithRetry(
508
+ method,
509
+ async () =>
510
+ fetch(`https://api.telegram.org/bot${configuredBotToken}/${method}`, {
511
+ method: "POST",
512
+ headers: { "content-type": "application/json" },
513
+ body: JSON.stringify(body),
514
+ signal: options?.signal,
515
+ }),
516
+ options,
517
+ );
518
+ }
519
+
520
+ export type TelegramBotIdentityResponse = Pick<
521
+ TelegramApiResponse<TelegramUser>,
522
+ "ok" | "result" | "description"
523
+ >;
524
+
525
+ export async function fetchTelegramBotIdentity(
526
+ botToken: string,
527
+ fetchImpl: typeof fetch = fetch,
528
+ ): Promise<TelegramBotIdentityResponse> {
529
+ const response = await fetchImpl(
530
+ `https://api.telegram.org/bot${botToken}/getMe`,
531
+ );
532
+ return response.json() as Promise<TelegramBotIdentityResponse>;
533
+ }
534
+
106
535
  export async function callTelegramMultipart<TResponse>(
107
536
  botToken: string | undefined,
108
537
  method: string,
@@ -110,30 +539,29 @@ export async function callTelegramMultipart<TResponse>(
110
539
  fileField: string,
111
540
  filePath: string,
112
541
  fileName: string,
113
- options?: { signal?: AbortSignal },
542
+ options?: TelegramApiCallOptions,
114
543
  ): Promise<TResponse> {
115
- if (!botToken) {
116
- throw new Error("Telegram bot token is not configured");
117
- }
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,
544
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
545
+ const fileBlob = await openAsBlob(filePath);
546
+ return callTelegramWithRetry(
547
+ method,
548
+ async () => {
549
+ const form = new FormData();
550
+ for (const [key, value] of Object.entries(fields)) {
551
+ form.set(key, value);
552
+ }
553
+ form.set(fileField, fileBlob, fileName);
554
+ return fetch(
555
+ `https://api.telegram.org/bot${configuredBotToken}/${method}`,
556
+ {
557
+ method: "POST",
558
+ body: form,
559
+ signal: options?.signal,
560
+ },
561
+ );
130
562
  },
563
+ options,
131
564
  );
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
565
  }
138
566
 
139
567
  export async function downloadTelegramFile(
@@ -141,26 +569,43 @@ export async function downloadTelegramFile(
141
569
  fileId: string,
142
570
  suggestedName: string,
143
571
  tempDir: string,
572
+ options?: TelegramFileDownloadOptions,
144
573
  ): Promise<string> {
145
- if (!botToken) {
146
- throw new Error("Telegram bot token is not configured");
147
- }
148
- const file = await callTelegram<TelegramGetFileResult>(botToken, "getFile", {
149
- file_id: fileId,
150
- });
574
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
575
+ const file = await callTelegram<TelegramGetFileResult>(
576
+ configuredBotToken,
577
+ "getFile",
578
+ { file_id: fileId },
579
+ { signal: options?.signal },
580
+ );
581
+ assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
151
582
  await mkdir(tempDir, { recursive: true });
152
583
  const targetPath = join(
153
584
  tempDir,
154
- `${Date.now()}-${sanitizeFileName(suggestedName)}`,
585
+ `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
155
586
  );
156
587
  const response = await fetch(
157
- `https://api.telegram.org/file/bot${botToken}/${file.file_path}`,
588
+ `https://api.telegram.org/file/bot${configuredBotToken}/${file.file_path}`,
589
+ { signal: options?.signal },
158
590
  );
159
591
  if (!response.ok) {
160
592
  throw new Error(`Failed to download Telegram file: ${response.status}`);
161
593
  }
162
- const arrayBuffer = await response.arrayBuffer();
163
- await writeFile(targetPath, Buffer.from(arrayBuffer));
594
+ const contentLength = response.headers?.get("content-length");
595
+ assertTelegramFileSizeWithinLimit(
596
+ contentLength ? Number.parseInt(contentLength, 10) : undefined,
597
+ options?.maxFileSizeBytes,
598
+ );
599
+ try {
600
+ await writeTelegramDownloadResponse(
601
+ response,
602
+ targetPath,
603
+ options?.maxFileSizeBytes,
604
+ );
605
+ } catch (error) {
606
+ await removeTelegramPartialDownload(targetPath);
607
+ throw error;
608
+ }
164
609
  return targetPath;
165
610
  }
166
611
 
@@ -182,6 +627,131 @@ export async function answerTelegramCallbackQuery(
182
627
  }
183
628
  }
184
629
 
630
+ export function createTelegramChatActionSender<TAction extends string>(
631
+ sendChatAction: (chatId: number, action: TAction) => Promise<unknown>,
632
+ action: TAction,
633
+ ): (chatId: number) => Promise<unknown> {
634
+ return (chatId) => sendChatAction(chatId, action);
635
+ }
636
+
637
+ export function createDefaultTelegramBridgeApiRuntime(deps: {
638
+ getBotToken: () => string | undefined;
639
+ recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
640
+ }): TelegramBridgeApiRuntime {
641
+ return createTelegramBridgeApiRuntime({
642
+ client: createTelegramApiClient(deps.getBotToken),
643
+ tempDir: TEMP_DIR,
644
+ maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
645
+ tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
646
+ recordRuntimeEvent: deps.recordRuntimeEvent,
647
+ });
648
+ }
649
+
650
+ export function createTelegramBridgeApiRuntime(
651
+ deps: TelegramBridgeApiRuntimeDeps,
652
+ ): TelegramBridgeApiRuntime {
653
+ const callRecorded = async <TResponse>(
654
+ method: string,
655
+ body: Record<string, unknown>,
656
+ options?: TelegramApiCallOptions,
657
+ ): Promise<TResponse> => {
658
+ try {
659
+ return await deps.client.call(method, body, options);
660
+ } catch (error) {
661
+ deps.recordRuntimeEvent("api", error, { method });
662
+ throw error;
663
+ }
664
+ };
665
+ return {
666
+ call: callRecorded,
667
+ callMultipart: async (
668
+ method,
669
+ fields,
670
+ fileField,
671
+ filePath,
672
+ fileName,
673
+ options,
674
+ ) => {
675
+ try {
676
+ return await deps.client.callMultipart(
677
+ method,
678
+ fields,
679
+ fileField,
680
+ filePath,
681
+ fileName,
682
+ options,
683
+ );
684
+ } catch (error) {
685
+ deps.recordRuntimeEvent("multipart", error, { method, fileName });
686
+ throw error;
687
+ }
688
+ },
689
+ downloadFile: async (fileId, suggestedName) => {
690
+ try {
691
+ return await deps.client.downloadFile(
692
+ fileId,
693
+ suggestedName,
694
+ deps.tempDir,
695
+ {
696
+ maxFileSizeBytes: deps.maxFileSizeBytes,
697
+ },
698
+ );
699
+ } catch (error) {
700
+ deps.recordRuntimeEvent("download", error, { suggestedName });
701
+ throw error;
702
+ }
703
+ },
704
+ deleteWebhook: (signal) =>
705
+ callRecorded<boolean>(
706
+ "deleteWebhook",
707
+ { drop_pending_updates: false },
708
+ { signal },
709
+ ),
710
+ getUpdates: (body, signal) =>
711
+ callRecorded<TelegramUpdate[]>("getUpdates", body, { signal }),
712
+ setMyCommands: (commands) =>
713
+ callRecorded<boolean>("setMyCommands", { commands }),
714
+ sendChatAction: (chatId, action) =>
715
+ callRecorded<boolean>("sendChatAction", {
716
+ chat_id: chatId,
717
+ action,
718
+ }),
719
+ sendTypingAction: createTelegramChatActionSender(
720
+ (chatId, action) =>
721
+ callRecorded<boolean>("sendChatAction", {
722
+ chat_id: chatId,
723
+ action,
724
+ }),
725
+ "typing",
726
+ ),
727
+ sendMessageDraft: (chatId, draftId, text) => {
728
+ if (text.length === 0) return Promise.resolve(false);
729
+ return callRecorded<boolean>("sendMessageDraft", {
730
+ chat_id: chatId,
731
+ draft_id: draftId,
732
+ text,
733
+ });
734
+ },
735
+ sendMessage: (body) =>
736
+ callRecorded<TelegramSentMessage>("sendMessage", body),
737
+ editMessageText: async (body) => {
738
+ try {
739
+ await deps.client.call("editMessageText", body);
740
+ return "edited";
741
+ } catch (error) {
742
+ if (isTelegramMessageNotModifiedError(error)) return "unchanged";
743
+ deps.recordRuntimeEvent("api", error, { method: "editMessageText" });
744
+ throw error;
745
+ }
746
+ },
747
+ answerCallbackQuery: (callbackQueryId, text) => {
748
+ return deps.client.answerCallbackQuery(callbackQueryId, text);
749
+ },
750
+ prepareTempDir: () =>
751
+ prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
752
+ };
753
+ }
754
+
185
755
  export function createTelegramApiClient(
186
756
  getBotToken: () => string | undefined,
187
757
  ): TelegramApiClient {
@@ -207,12 +777,13 @@ export function createTelegramApiClient(
207
777
  options,
208
778
  );
209
779
  },
210
- downloadFile: async (fileId, suggestedName, tempDir) => {
780
+ downloadFile: async (fileId, suggestedName, tempDir, options) => {
211
781
  return downloadTelegramFile(
212
782
  getBotToken(),
213
783
  fileId,
214
784
  suggestedName,
215
785
  tempDir,
786
+ options,
216
787
  );
217
788
  },
218
789
  answerCallbackQuery: async (callbackQueryId, text) => {