@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/README.md +40 -26
- package/docs/architecture.md +62 -35
- package/index.ts +388 -1936
- package/lib/api.ts +647 -76
- package/lib/attachments.ts +128 -16
- package/lib/commands.ts +721 -0
- package/lib/config.ts +157 -0
- package/lib/media.ts +211 -36
- package/lib/menu.ts +920 -338
- package/lib/model.ts +647 -0
- package/lib/pi.ts +80 -0
- package/lib/polling.ts +264 -18
- package/lib/preview.ts +451 -29
- package/lib/queue.ts +1134 -110
- package/lib/registration.ts +127 -28
- package/lib/rendering.ts +575 -281
- package/lib/replies.ts +198 -8
- package/lib/runtime.ts +475 -0
- package/lib/setup.ts +129 -1
- package/lib/status.ts +428 -13
- package/lib/turns.ts +207 -17
- package/lib/updates.ts +392 -99
- package/package.json +18 -3
- package/AGENTS.md +0 -91
- package/BACKLOG.md +0 -5
- package/CHANGELOG.md +0 -23
- package/lib/model-switch.ts +0 -62
- package/tests/api.test.ts +0 -89
- package/tests/attachments.test.ts +0 -132
- package/tests/config.test.ts +0 -80
- package/tests/media.test.ts +0 -77
- package/tests/menu.test.ts +0 -676
- package/tests/polling.test.ts +0 -129
- package/tests/preview.test.ts +0 -441
- package/tests/queue.test.ts +0 -3245
- package/tests/registration.test.ts +0 -268
- package/tests/rendering.test.ts +0 -475
- package/tests/replies.test.ts +0 -142
- package/tests/turns.test.ts +0 -132
- package/tests/updates.test.ts +0 -357
package/lib/api.ts
CHANGED
|
@@ -1,35 +1,211 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Telegram API
|
|
3
|
-
* Wraps bot API calls, file downloads,
|
|
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 {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
`
|
|
92
|
-
{
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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?:
|
|
542
|
+
options?: TelegramApiCallOptions,
|
|
114
543
|
): Promise<TResponse> {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
`${
|
|
585
|
+
`${randomUUID()}-${sanitizeFileName(suggestedName)}`,
|
|
155
586
|
);
|
|
156
587
|
const response = await fetch(
|
|
157
|
-
`https://api.telegram.org/file/bot${
|
|
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
|
|
163
|
-
|
|
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) => {
|