@llblab/pi-telegram 0.3.0 → 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.
@@ -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
+ }