@llblab/pi-telegram 0.2.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +52 -19
  2. package/docs/README.md +2 -3
  3. package/docs/architecture.md +62 -31
  4. package/docs/locks.md +136 -0
  5. package/index.ts +323 -1880
  6. package/lib/api.ts +396 -60
  7. package/lib/attachments.ts +128 -16
  8. package/lib/commands.ts +648 -14
  9. package/lib/config.ts +169 -0
  10. package/lib/handlers.ts +474 -0
  11. package/lib/locks.ts +306 -0
  12. package/lib/media.ts +196 -46
  13. package/lib/menu.ts +920 -338
  14. package/lib/model.ts +647 -0
  15. package/lib/pi.ts +90 -0
  16. package/lib/polling.ts +240 -14
  17. package/lib/preview.ts +420 -25
  18. package/lib/queue.ts +1137 -110
  19. package/lib/registration.ts +214 -31
  20. package/lib/rendering.ts +560 -366
  21. package/lib/replies.ts +198 -8
  22. package/lib/routing.ts +217 -0
  23. package/lib/runtime.ts +475 -0
  24. package/lib/setup.ts +143 -1
  25. package/lib/status.ts +432 -13
  26. package/lib/turns.ts +217 -36
  27. package/lib/updates.ts +340 -109
  28. package/package.json +18 -3
  29. package/AGENTS.md +0 -91
  30. package/BACKLOG.md +0 -5
  31. package/CHANGELOG.md +0 -34
  32. package/lib/model-switch.ts +0 -62
  33. package/lib/types.ts +0 -137
  34. package/tests/api.test.ts +0 -331
  35. package/tests/attachments.test.ts +0 -132
  36. package/tests/commands.test.ts +0 -85
  37. package/tests/config.test.ts +0 -80
  38. package/tests/media.test.ts +0 -166
  39. package/tests/menu.test.ts +0 -676
  40. package/tests/polling.test.ts +0 -202
  41. package/tests/preview.test.ts +0 -480
  42. package/tests/queue.test.ts +0 -3245
  43. package/tests/registration.test.ts +0 -268
  44. package/tests/rendering.test.ts +0 -526
  45. package/tests/replies.test.ts +0 -142
  46. package/tests/turns.test.ts +0 -247
  47. package/tests/updates.test.ts +0 -416
package/lib/api.ts CHANGED
@@ -1,30 +1,181 @@
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
6
  import { randomUUID } from "node:crypto";
7
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";
8
+ import { mkdir, readdir, stat, unlink, writeFile } from "node:fs/promises";
9
+ import { homedir } from "node:os";
16
10
  import { join } from "node:path";
17
11
  import { Readable, Transform } from "node:stream";
18
12
  import { pipeline } from "node:stream/promises";
19
13
 
20
- export interface TelegramConfig {
21
- botToken?: string;
22
- botUsername?: string;
23
- botId?: number;
24
- allowedUserId?: number;
25
- 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;
108
+ }
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";
26
129
  }
27
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
+
28
179
  interface TelegramApiResponse<T> {
29
180
  ok: boolean;
30
181
  result?: T;
@@ -76,6 +227,59 @@ export interface TelegramApiClient {
76
227
  ) => Promise<void>;
77
228
  }
78
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
+
79
283
  function sanitizeFileName(name: string): string {
80
284
  return name.replace(/[^a-zA-Z0-9._-]+/g, "_");
81
285
  }
@@ -94,6 +298,12 @@ class TelegramApiHttpError extends Error {
94
298
  }
95
299
  }
96
300
 
301
+ export function isTelegramMessageNotModifiedError(error: unknown): boolean {
302
+ return (
303
+ error instanceof Error && error.message.includes("message is not modified")
304
+ );
305
+ }
306
+
97
307
  function isRetryableTelegramApiError(error: unknown): boolean {
98
308
  return (
99
309
  error instanceof TelegramApiHttpError &&
@@ -160,9 +370,7 @@ async function writeTelegramDownloadResponse(
160
370
  return;
161
371
  }
162
372
  await pipeline(
163
- Readable.fromWeb(
164
- response.body as unknown as Parameters<typeof Readable.fromWeb>[0],
165
- ),
373
+ Readable.from(response.body, { objectMode: false }),
166
374
  createTelegramDownloadLimitTransform(maxFileSizeBytes),
167
375
  createWriteStream(targetPath),
168
376
  );
@@ -247,30 +455,6 @@ async function callTelegramWithRetry<TResponse>(
247
455
  }
248
456
  }
249
457
 
250
- export async function readTelegramConfig(
251
- configPath: string,
252
- ): Promise<TelegramConfig> {
253
- try {
254
- const content = await readFile(configPath, "utf8");
255
- return JSON.parse(content) as TelegramConfig;
256
- } catch {
257
- return {};
258
- }
259
- }
260
-
261
- export async function writeTelegramConfig(
262
- agentDir: string,
263
- configPath: string,
264
- config: TelegramConfig,
265
- ): Promise<void> {
266
- await mkdir(agentDir, { recursive: true });
267
- await writeFile(
268
- configPath,
269
- JSON.stringify(config, null, "\t") + "\n",
270
- "utf8",
271
- );
272
- }
273
-
274
458
  export async function cleanupTelegramTempFiles(
275
459
  tempDir: string,
276
460
  maxAgeMs: number,
@@ -298,19 +482,32 @@ export async function cleanupTelegramTempFiles(
298
482
  return removedCount;
299
483
  }
300
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
+
301
500
  export async function callTelegram<TResponse>(
302
501
  botToken: string | undefined,
303
502
  method: string,
304
503
  body: Record<string, unknown>,
305
504
  options?: TelegramApiCallOptions,
306
505
  ): Promise<TResponse> {
307
- if (!botToken) {
308
- throw new Error("Telegram bot token is not configured");
309
- }
506
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
310
507
  return callTelegramWithRetry(
311
508
  method,
312
509
  async () =>
313
- fetch(`https://api.telegram.org/bot${botToken}/${method}`, {
510
+ fetch(`https://api.telegram.org/bot${configuredBotToken}/${method}`, {
314
511
  method: "POST",
315
512
  headers: { "content-type": "application/json" },
316
513
  body: JSON.stringify(body),
@@ -320,6 +517,21 @@ export async function callTelegram<TResponse>(
320
517
  );
321
518
  }
322
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
+
323
535
  export async function callTelegramMultipart<TResponse>(
324
536
  botToken: string | undefined,
325
537
  method: string,
@@ -329,9 +541,7 @@ export async function callTelegramMultipart<TResponse>(
329
541
  fileName: string,
330
542
  options?: TelegramApiCallOptions,
331
543
  ): Promise<TResponse> {
332
- if (!botToken) {
333
- throw new Error("Telegram bot token is not configured");
334
- }
544
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
335
545
  const fileBlob = await openAsBlob(filePath);
336
546
  return callTelegramWithRetry(
337
547
  method,
@@ -341,11 +551,14 @@ export async function callTelegramMultipart<TResponse>(
341
551
  form.set(key, value);
342
552
  }
343
553
  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
- });
554
+ return fetch(
555
+ `https://api.telegram.org/bot${configuredBotToken}/${method}`,
556
+ {
557
+ method: "POST",
558
+ body: form,
559
+ signal: options?.signal,
560
+ },
561
+ );
349
562
  },
350
563
  options,
351
564
  );
@@ -358,11 +571,9 @@ export async function downloadTelegramFile(
358
571
  tempDir: string,
359
572
  options?: TelegramFileDownloadOptions,
360
573
  ): Promise<string> {
361
- if (!botToken) {
362
- throw new Error("Telegram bot token is not configured");
363
- }
574
+ const configuredBotToken = assertTelegramBotTokenConfigured(botToken);
364
575
  const file = await callTelegram<TelegramGetFileResult>(
365
- botToken,
576
+ configuredBotToken,
366
577
  "getFile",
367
578
  { file_id: fileId },
368
579
  { signal: options?.signal },
@@ -374,7 +585,7 @@ export async function downloadTelegramFile(
374
585
  `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
375
586
  );
376
587
  const response = await fetch(
377
- `https://api.telegram.org/file/bot${botToken}/${file.file_path}`,
588
+ `https://api.telegram.org/file/bot${configuredBotToken}/${file.file_path}`,
378
589
  { signal: options?.signal },
379
590
  );
380
591
  if (!response.ok) {
@@ -416,6 +627,131 @@ export async function answerTelegramCallbackQuery(
416
627
  }
417
628
  }
418
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
+
419
755
  export function createTelegramApiClient(
420
756
  getBotToken: () => string | undefined,
421
757
  ): TelegramApiClient {