@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/config.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Telegram bridge config and pairing helpers
3
+ * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects
4
+ */
5
+
6
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+
10
+ import type { TelegramAttachmentHandlerConfig } from "./handlers.ts";
11
+
12
+ function getAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getConfigPath(): string {
19
+ return join(getAgentDir(), "telegram.json");
20
+ }
21
+
22
+ export interface TelegramConfig {
23
+ botToken?: string;
24
+ botUsername?: string;
25
+ botId?: number;
26
+ allowedUserId?: number;
27
+ lastUpdateId?: number;
28
+ attachmentHandlers?: TelegramAttachmentHandlerConfig[];
29
+ }
30
+
31
+ export interface TelegramConfigStore {
32
+ get: () => TelegramConfig;
33
+ set: (config: TelegramConfig) => void;
34
+ update: (mutate: (config: TelegramConfig) => void) => void;
35
+ getBotToken: () => string | undefined;
36
+ hasBotToken: () => boolean;
37
+ getAllowedUserId: () => number | undefined;
38
+ getAttachmentHandlers: () => TelegramAttachmentHandlerConfig[] | undefined;
39
+ setAllowedUserId: (userId: number) => void;
40
+ load: () => Promise<void>;
41
+ persist: (config?: TelegramConfig) => Promise<void>;
42
+ }
43
+
44
+ export interface TelegramConfigStoreOptions {
45
+ initialConfig?: TelegramConfig;
46
+ agentDir?: string;
47
+ configPath?: string;
48
+ }
49
+
50
+ export async function readTelegramConfig(
51
+ configPath: string,
52
+ ): Promise<TelegramConfig> {
53
+ try {
54
+ const content = await readFile(configPath, "utf8");
55
+ return JSON.parse(content) as TelegramConfig;
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ export async function writeTelegramConfig(
62
+ agentDir: string,
63
+ configPath: string,
64
+ config: TelegramConfig,
65
+ ): Promise<void> {
66
+ await mkdir(agentDir, { recursive: true });
67
+ await writeFile(configPath, JSON.stringify(config, null, "\t") + "\n", {
68
+ encoding: "utf8",
69
+ mode: 0o600,
70
+ });
71
+ await chmod(configPath, 0o600);
72
+ }
73
+
74
+ export function createTelegramConfigStore(
75
+ options: TelegramConfigStoreOptions = {},
76
+ ): TelegramConfigStore {
77
+ let config: TelegramConfig = options.initialConfig ?? {};
78
+ const agentDir = options.agentDir ?? getAgentDir();
79
+ const configPath = options.configPath ?? getConfigPath();
80
+ return {
81
+ get: () => config,
82
+ set: (nextConfig) => {
83
+ config = nextConfig;
84
+ },
85
+ update: (mutate) => {
86
+ mutate(config);
87
+ },
88
+ getBotToken: () => config.botToken,
89
+ hasBotToken: () => !!config.botToken,
90
+ getAllowedUserId: () => config.allowedUserId,
91
+ getAttachmentHandlers: () => config.attachmentHandlers,
92
+ setAllowedUserId: (userId) => {
93
+ config.allowedUserId = userId;
94
+ },
95
+ load: async () => {
96
+ config = await readTelegramConfig(configPath);
97
+ },
98
+ persist: async (nextConfig = config) => {
99
+ await writeTelegramConfig(agentDir, configPath, nextConfig);
100
+ },
101
+ };
102
+ }
103
+
104
+ export type TelegramAuthorizationState =
105
+ | { kind: "pair"; userId: number }
106
+ | { kind: "allow" }
107
+ | { kind: "deny" };
108
+
109
+ export interface TelegramUserPairingDeps<TContext> {
110
+ allowedUserId?: number;
111
+ ctx: TContext;
112
+ setAllowedUserId: (userId: number) => void;
113
+ persistConfig: () => Promise<void>;
114
+ updateStatus: (ctx: TContext) => void;
115
+ }
116
+
117
+ export interface TelegramUserPairingRuntimeDeps<TContext> {
118
+ getAllowedUserId: () => number | undefined;
119
+ setAllowedUserId: (userId: number) => void;
120
+ persistConfig: () => Promise<void>;
121
+ updateStatus: (ctx: TContext) => void;
122
+ }
123
+
124
+ export interface TelegramUserPairingRuntime<TContext> {
125
+ pairIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
126
+ }
127
+
128
+ export function getTelegramAuthorizationState(
129
+ userId: number,
130
+ allowedUserId?: number,
131
+ ): TelegramAuthorizationState {
132
+ if (allowedUserId === undefined) {
133
+ return { kind: "pair", userId };
134
+ }
135
+ if (userId === allowedUserId) {
136
+ return { kind: "allow" };
137
+ }
138
+ return { kind: "deny" };
139
+ }
140
+
141
+ export async function pairTelegramUserIfNeeded<TContext>(
142
+ userId: number,
143
+ deps: TelegramUserPairingDeps<TContext>,
144
+ ): Promise<boolean> {
145
+ const authorization = getTelegramAuthorizationState(
146
+ userId,
147
+ deps.allowedUserId,
148
+ );
149
+ if (authorization.kind !== "pair") return false;
150
+ deps.setAllowedUserId(authorization.userId);
151
+ await deps.persistConfig();
152
+ deps.updateStatus(deps.ctx);
153
+ return true;
154
+ }
155
+
156
+ export function createTelegramUserPairingRuntime<TContext>(
157
+ deps: TelegramUserPairingRuntimeDeps<TContext>,
158
+ ): TelegramUserPairingRuntime<TContext> {
159
+ return {
160
+ pairIfNeeded: (userId, ctx) =>
161
+ pairTelegramUserIfNeeded(userId, {
162
+ allowedUserId: deps.getAllowedUserId(),
163
+ ctx,
164
+ setAllowedUserId: deps.setAllowedUserId,
165
+ persistConfig: deps.persistConfig,
166
+ updateStatus: deps.updateStatus,
167
+ }),
168
+ };
169
+ }
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Telegram inbound attachment handler pipeline
3
+ * Owns MIME/type matching plus command and auto-tool execution for downloaded inbound files before prompt enqueueing
4
+ */
5
+
6
+ import { readFile } from "node:fs/promises";
7
+ import { homedir } from "node:os";
8
+ import { basename, isAbsolute, join, resolve } from "node:path";
9
+
10
+ const DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS = 120_000;
11
+
12
+ function getDefaultAgentDir(): string {
13
+ return process.env.PI_CODING_AGENT_DIR
14
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ function getDefaultAutoToolsPath(): string {
19
+ return join(getDefaultAgentDir(), "auto-tools.json");
20
+ }
21
+
22
+ export interface TelegramAttachmentHandlerConfig {
23
+ match?: string | string[];
24
+ mime?: string | string[];
25
+ type?: string | string[];
26
+ command?: string;
27
+ tool?: string;
28
+ args?: Record<string, unknown>;
29
+ timeoutMs?: number;
30
+ }
31
+
32
+ export interface TelegramAttachmentHandlerFile {
33
+ path: string;
34
+ fileName?: string;
35
+ mimeType?: string;
36
+ kind?: string;
37
+ isImage?: boolean;
38
+ }
39
+
40
+ export interface TelegramAttachmentHandlerOutput {
41
+ file: TelegramAttachmentHandlerFile;
42
+ output: string;
43
+ handler: TelegramAttachmentHandlerConfig;
44
+ }
45
+
46
+ export interface TelegramAttachmentHandlerProcessResult<
47
+ TFile extends TelegramAttachmentHandlerFile = TelegramAttachmentHandlerFile,
48
+ > {
49
+ rawText: string;
50
+ promptFiles: TFile[];
51
+ handlerOutputs: string[];
52
+ handledFiles: TelegramAttachmentHandlerOutput[];
53
+ }
54
+
55
+ export interface TelegramAttachmentHandlerExecOptions {
56
+ cwd?: string;
57
+ timeout?: number;
58
+ signal?: AbortSignal;
59
+ }
60
+
61
+ export interface TelegramAttachmentHandlerExecResult {
62
+ stdout: string;
63
+ stderr: string;
64
+ code: number;
65
+ killed: boolean;
66
+ }
67
+
68
+ export interface TelegramAttachmentHandlerRuntimeContext {
69
+ cwd: string;
70
+ }
71
+
72
+ export interface TelegramAttachmentHandlerRuntimeDeps<TContext> {
73
+ getHandlers: () => TelegramAttachmentHandlerConfig[] | undefined;
74
+ execCommand: (
75
+ command: string,
76
+ args: string[],
77
+ options?: TelegramAttachmentHandlerExecOptions,
78
+ ) => Promise<TelegramAttachmentHandlerExecResult>;
79
+ getCwd: (ctx: TContext) => string;
80
+ readTextFile?: (path: string) => Promise<string>;
81
+ autoToolsPath?: string;
82
+ recordRuntimeEvent?: (
83
+ category: string,
84
+ error: unknown,
85
+ details?: Record<string, unknown>,
86
+ ) => void;
87
+ }
88
+
89
+ export interface TelegramAttachmentHandlerRuntime<TContext> {
90
+ process: <TFile extends TelegramAttachmentHandlerFile>(
91
+ files: TFile[],
92
+ rawText: string,
93
+ ctx: TContext,
94
+ ) => Promise<TelegramAttachmentHandlerProcessResult<TFile>>;
95
+ }
96
+
97
+ interface AttachmentHandlerInvocation {
98
+ command: string;
99
+ args: string[];
100
+ }
101
+
102
+ interface AutoToolConfig {
103
+ name: string;
104
+ script: string;
105
+ args: string[];
106
+ }
107
+
108
+ function normalizeStringList(value: string | string[] | undefined): string[] {
109
+ if (Array.isArray(value)) {
110
+ return value.map(String).map((item) => item.trim()).filter(Boolean);
111
+ }
112
+ if (typeof value === "string" && value.trim()) return [value.trim()];
113
+ return [];
114
+ }
115
+
116
+ function matchesWildcard(pattern: string, value: string | undefined): boolean {
117
+ if (!value) return false;
118
+ const normalizedPattern = pattern.toLowerCase();
119
+ const normalizedValue = value.toLowerCase();
120
+ if (normalizedPattern === "*") return true;
121
+ const escaped = normalizedPattern
122
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
123
+ .replace(/\\\*/g, ".*");
124
+ return new RegExp(`^${escaped}$`).test(normalizedValue);
125
+ }
126
+
127
+ function handlerHasSelectors(handler: TelegramAttachmentHandlerConfig): boolean {
128
+ return (
129
+ normalizeStringList(handler.match).length > 0 ||
130
+ normalizeStringList(handler.mime).length > 0 ||
131
+ normalizeStringList(handler.type).length > 0
132
+ );
133
+ }
134
+
135
+ function matchesAnyPattern(patterns: string[], value: string | undefined): boolean {
136
+ return patterns.some((pattern) => matchesWildcard(pattern, value));
137
+ }
138
+
139
+ export function telegramAttachmentHandlerMatchesFile(
140
+ handler: TelegramAttachmentHandlerConfig,
141
+ file: TelegramAttachmentHandlerFile,
142
+ ): boolean {
143
+ if (!handlerHasSelectors(handler)) return true;
144
+ const matchPatterns = normalizeStringList(handler.match);
145
+ const mimePatterns = normalizeStringList(handler.mime);
146
+ const typePatterns = normalizeStringList(handler.type);
147
+ if (matchesAnyPattern(mimePatterns, file.mimeType)) return true;
148
+ if (matchesAnyPattern(typePatterns, file.kind)) return true;
149
+ if (matchesAnyPattern(matchPatterns, file.mimeType)) return true;
150
+ return matchesAnyPattern(matchPatterns, file.kind);
151
+ }
152
+
153
+ export function findTelegramAttachmentHandler(
154
+ handlers: TelegramAttachmentHandlerConfig[] | undefined,
155
+ file: TelegramAttachmentHandlerFile,
156
+ ): TelegramAttachmentHandlerConfig | undefined {
157
+ if (!Array.isArray(handlers)) return undefined;
158
+ return handlers.find(
159
+ (handler) =>
160
+ !!handler &&
161
+ typeof handler === "object" &&
162
+ telegramAttachmentHandlerMatchesFile(handler, file),
163
+ );
164
+ }
165
+
166
+ function hasAttachmentPlaceholder(value: string): boolean {
167
+ return /\{(?:filename|path|basename|mime|type)\}/.test(value);
168
+ }
169
+
170
+ export function substituteTelegramAttachmentHandlerToken(
171
+ token: string,
172
+ file: TelegramAttachmentHandlerFile,
173
+ ): string {
174
+ const replacements: Record<string, string> = {
175
+ "{filename}": file.path,
176
+ "{path}": file.path,
177
+ "{basename}": file.fileName || basename(file.path),
178
+ "{mime}": file.mimeType ?? "",
179
+ "{type}": file.kind ?? "",
180
+ };
181
+ let result = token;
182
+ for (const [key, value] of Object.entries(replacements)) {
183
+ result = result.split(key).join(value);
184
+ }
185
+ return result;
186
+ }
187
+
188
+ export function splitTelegramAttachmentHandlerCommand(input: string): string[] {
189
+ const words: string[] = [];
190
+ let current = "";
191
+ let quote: "'" | '"' | undefined;
192
+ let escaped = false;
193
+ let active = false;
194
+ for (const char of input) {
195
+ if (escaped) {
196
+ current += char;
197
+ escaped = false;
198
+ active = true;
199
+ continue;
200
+ }
201
+ if (char === "\\" && quote !== "'") {
202
+ escaped = true;
203
+ active = true;
204
+ continue;
205
+ }
206
+ if (quote) {
207
+ if (char === quote) quote = undefined;
208
+ else current += char;
209
+ active = true;
210
+ continue;
211
+ }
212
+ if (char === "'" || char === '"') {
213
+ quote = char;
214
+ active = true;
215
+ continue;
216
+ }
217
+ if (/\s/.test(char)) {
218
+ if (active) words.push(current);
219
+ if (active) current = "";
220
+ active = false;
221
+ continue;
222
+ }
223
+ current += char;
224
+ active = true;
225
+ }
226
+ if (escaped) current += "\\";
227
+ if (active || current) words.push(current);
228
+ return words;
229
+ }
230
+
231
+ function expandExecutablePath(command: string, cwd: string): string {
232
+ if (command === "~") return homedir();
233
+ if (command.startsWith("~/")) return resolve(homedir(), command.slice(2));
234
+ if (command.includes("/") && !isAbsolute(command)) {
235
+ return resolve(cwd, command);
236
+ }
237
+ return command;
238
+ }
239
+
240
+ export function buildTelegramAttachmentCommandInvocation(
241
+ commandTemplate: string,
242
+ file: TelegramAttachmentHandlerFile,
243
+ cwd: string,
244
+ ): AttachmentHandlerInvocation {
245
+ const parts = splitTelegramAttachmentHandlerCommand(commandTemplate);
246
+ const commandPart = parts[0];
247
+ if (!commandPart) throw new Error("Attachment handler command is empty");
248
+ const hadPlaceholder = parts.some(hasAttachmentPlaceholder);
249
+ const command = expandExecutablePath(
250
+ substituteTelegramAttachmentHandlerToken(commandPart, file),
251
+ cwd,
252
+ );
253
+ const args = parts
254
+ .slice(1)
255
+ .map((part) => substituteTelegramAttachmentHandlerToken(part, file));
256
+ if (!hadPlaceholder) args.push(file.path);
257
+ return { command, args };
258
+ }
259
+
260
+ function normalizeAutoToolName(name: string): string {
261
+ return name
262
+ .trim()
263
+ .toLowerCase()
264
+ .replace(/[^a-z0-9_]/g, "_")
265
+ .replace(/_+/g, "_")
266
+ .replace(/^_+|_+$/g, "");
267
+ }
268
+
269
+ function normalizeAutoToolArgs(value: unknown): string[] {
270
+ const source = Array.isArray(value)
271
+ ? value
272
+ : typeof value === "string"
273
+ ? value.split(",")
274
+ : [];
275
+ const args: string[] = [];
276
+ const seen = new Set<string>();
277
+ for (const item of source) {
278
+ const arg = normalizeAutoToolName(String(item));
279
+ if (!arg || seen.has(arg)) continue;
280
+ seen.add(arg);
281
+ args.push(arg);
282
+ }
283
+ return args;
284
+ }
285
+
286
+ export function parseTelegramAutoToolsRegistry(
287
+ content: string,
288
+ ): Map<string, AutoToolConfig> {
289
+ const raw = JSON.parse(content) as unknown;
290
+ const entries = Array.isArray(raw)
291
+ ? raw.map((value) => [undefined, value] as const)
292
+ : raw && typeof raw === "object"
293
+ ? Object.entries(raw as Record<string, unknown>)
294
+ : [];
295
+ const tools = new Map<string, AutoToolConfig>();
296
+ for (const [key, value] of entries) {
297
+ if (!value || typeof value !== "object") continue;
298
+ const record = value as Record<string, unknown>;
299
+ const name = normalizeAutoToolName(
300
+ typeof record.name === "string" ? record.name : (key ?? ""),
301
+ );
302
+ const script = typeof record.script === "string" ? record.script.trim() : "";
303
+ if (!name || !script) continue;
304
+ tools.set(name, { name, script, args: normalizeAutoToolArgs(record.args) });
305
+ }
306
+ return tools;
307
+ }
308
+
309
+ async function readTelegramAutoToolsRegistry(
310
+ path: string,
311
+ readTextFile: (path: string) => Promise<string>,
312
+ ): Promise<Map<string, AutoToolConfig>> {
313
+ try {
314
+ return parseTelegramAutoToolsRegistry(await readTextFile(path));
315
+ } catch {
316
+ return new Map();
317
+ }
318
+ }
319
+
320
+ function getConfiguredToolArgValue(
321
+ value: unknown,
322
+ file: TelegramAttachmentHandlerFile,
323
+ ): string | undefined {
324
+ if (value === undefined || value === null) return undefined;
325
+ return substituteTelegramAttachmentHandlerToken(String(value), file);
326
+ }
327
+
328
+ function getDefaultToolArgValue(
329
+ arg: string,
330
+ file: TelegramAttachmentHandlerFile,
331
+ ): string {
332
+ if (["file", "filename", "path"].includes(arg)) return file.path;
333
+ if (["basename", "name"].includes(arg)) {
334
+ return file.fileName || basename(file.path);
335
+ }
336
+ if (["mime", "mime_type", "mimetype"].includes(arg)) return file.mimeType ?? "";
337
+ if (["type", "kind"].includes(arg)) return file.kind ?? "";
338
+ return "";
339
+ }
340
+
341
+ async function buildTelegramAttachmentToolInvocation(
342
+ handler: TelegramAttachmentHandlerConfig,
343
+ file: TelegramAttachmentHandlerFile,
344
+ cwd: string,
345
+ deps: Pick<
346
+ TelegramAttachmentHandlerRuntimeDeps<unknown>,
347
+ "readTextFile" | "autoToolsPath"
348
+ >,
349
+ ): Promise<AttachmentHandlerInvocation> {
350
+ const toolName = normalizeAutoToolName(handler.tool ?? "");
351
+ if (!toolName) throw new Error("Attachment handler tool is empty");
352
+ const readRegistryFile =
353
+ deps.readTextFile ?? ((path: string) => readFile(path, "utf8"));
354
+ const registry = await readTelegramAutoToolsRegistry(
355
+ deps.autoToolsPath ?? getDefaultAutoToolsPath(),
356
+ readRegistryFile,
357
+ );
358
+ const tool = registry.get(toolName);
359
+ if (!tool) {
360
+ throw new Error(`Attachment handler tool not found in auto-tools: ${toolName}`);
361
+ }
362
+ const script = expandExecutablePath(tool.script, cwd);
363
+ const args = tool.args.map(
364
+ (arg) =>
365
+ getConfiguredToolArgValue(handler.args?.[arg], file) ??
366
+ getDefaultToolArgValue(arg, file),
367
+ );
368
+ return { command: script, args };
369
+ }
370
+
371
+ function getTelegramAttachmentHandlerTimeout(
372
+ handler: TelegramAttachmentHandlerConfig,
373
+ ): number {
374
+ return typeof handler.timeoutMs === "number" &&
375
+ Number.isFinite(handler.timeoutMs) &&
376
+ handler.timeoutMs > 0
377
+ ? handler.timeoutMs
378
+ : DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS;
379
+ }
380
+
381
+ function getTelegramAttachmentHandlerKind(handler: TelegramAttachmentHandlerConfig): string {
382
+ if (handler.command) return "command";
383
+ if (handler.tool) return "tool";
384
+ return "unknown";
385
+ }
386
+
387
+ function formatTelegramAttachmentHandlerFailure(
388
+ result: TelegramAttachmentHandlerExecResult,
389
+ ): string {
390
+ const parts = [
391
+ `Attachment handler exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
392
+ ];
393
+ if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
394
+ if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
395
+ return parts.join("\n\n");
396
+ }
397
+
398
+ async function executeTelegramAttachmentHandler(
399
+ handler: TelegramAttachmentHandlerConfig,
400
+ file: TelegramAttachmentHandlerFile,
401
+ cwd: string,
402
+ deps: Pick<
403
+ TelegramAttachmentHandlerRuntimeDeps<unknown>,
404
+ "execCommand" | "readTextFile" | "autoToolsPath"
405
+ >,
406
+ ): Promise<string> {
407
+ const invocation = handler.command
408
+ ? buildTelegramAttachmentCommandInvocation(handler.command, file, cwd)
409
+ : await buildTelegramAttachmentToolInvocation(handler, file, cwd, deps);
410
+ const result = await deps.execCommand(invocation.command, invocation.args, {
411
+ cwd,
412
+ timeout: getTelegramAttachmentHandlerTimeout(handler),
413
+ });
414
+ if (result.code !== 0) throw new Error(formatTelegramAttachmentHandlerFailure(result));
415
+ return result.stdout.trim();
416
+ }
417
+
418
+ export async function processTelegramAttachmentHandlers<
419
+ TFile extends TelegramAttachmentHandlerFile,
420
+ >(options: {
421
+ files: TFile[];
422
+ rawText: string;
423
+ handlers?: TelegramAttachmentHandlerConfig[];
424
+ cwd: string;
425
+ execCommand: TelegramAttachmentHandlerRuntimeDeps<unknown>["execCommand"];
426
+ readTextFile?: (path: string) => Promise<string>;
427
+ autoToolsPath?: string;
428
+ recordRuntimeEvent?: TelegramAttachmentHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
429
+ }): Promise<TelegramAttachmentHandlerProcessResult<TFile>> {
430
+ const promptFiles: TFile[] = [...options.files];
431
+ const outputs: TelegramAttachmentHandlerOutput[] = [];
432
+ for (const file of options.files) {
433
+ const handler = findTelegramAttachmentHandler(options.handlers, file);
434
+ if (!handler) continue;
435
+ try {
436
+ const output = await executeTelegramAttachmentHandler(
437
+ handler,
438
+ file,
439
+ options.cwd,
440
+ options,
441
+ );
442
+ if (output) outputs.push({ file, output, handler });
443
+ } catch (error) {
444
+ options.recordRuntimeEvent?.("attachment-handler", error, {
445
+ fileName: file.fileName || basename(file.path),
446
+ handler: getTelegramAttachmentHandlerKind(handler),
447
+ });
448
+ }
449
+ }
450
+ return {
451
+ rawText: options.rawText,
452
+ promptFiles,
453
+ handlerOutputs: outputs.map((output) => output.output),
454
+ handledFiles: outputs,
455
+ };
456
+ }
457
+
458
+ export function createTelegramAttachmentHandlerRuntime<TContext>(
459
+ deps: TelegramAttachmentHandlerRuntimeDeps<TContext>,
460
+ ): TelegramAttachmentHandlerRuntime<TContext> {
461
+ return {
462
+ process: (files, rawText, ctx) =>
463
+ processTelegramAttachmentHandlers({
464
+ files,
465
+ rawText,
466
+ handlers: deps.getHandlers(),
467
+ cwd: deps.getCwd(ctx),
468
+ execCommand: deps.execCommand,
469
+ readTextFile: deps.readTextFile,
470
+ autoToolsPath: deps.autoToolsPath,
471
+ recordRuntimeEvent: deps.recordRuntimeEvent,
472
+ }),
473
+ };
474
+ }