@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/AGENTS.md +2 -1
- package/CHANGELOG.md +15 -0
- package/README.md +16 -13
- package/docs/architecture.md +26 -16
- package/index.ts +199 -251
- package/lib/api.ts +277 -42
- package/lib/commands.ts +87 -0
- package/lib/media.ts +70 -1
- package/lib/polling.ts +25 -5
- package/lib/preview.ts +239 -0
- package/lib/rendering.ts +686 -49
- package/lib/replies.ts +2 -181
- package/lib/turns.ts +86 -0
- package/lib/types.ts +137 -0
- package/lib/updates.ts +64 -2
- package/package.json +1 -1
- package/tests/api.test.ts +243 -1
- package/tests/commands.test.ts +85 -0
- package/tests/media.test.ts +90 -1
- package/tests/menu.test.ts +46 -15
- package/tests/polling.test.ts +73 -0
- package/tests/preview.test.ts +480 -0
- package/tests/queue.test.ts +3 -0
- package/tests/rendering.test.ts +175 -2
- package/tests/replies.test.ts +2 -222
- package/tests/turns.test.ts +115 -0
- package/tests/updates.test.ts +72 -7
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 {
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
method
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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?:
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
method
|
|
128
|
-
|
|
129
|
-
|
|
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>(
|
|
149
|
-
|
|
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
|
-
`${
|
|
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
|
|
163
|
-
|
|
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) => {
|
package/lib/commands.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
119
|
-
deps.onErrorStatus(message);
|
|
139
|
+
deps.onErrorStatus(getTelegramPollingErrorMessage(error));
|
|
120
140
|
await deps.sleep(3000);
|
|
121
141
|
deps.onStatusReset();
|
|
122
142
|
}
|