@llblab/pi-telegram 0.4.0 → 0.5.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/lib/commands.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Telegram command routing helpers
3
- * Owns slash-command normalization and command side-effect branching behind runtime ports
3
+ * Owns Telegram slash-command normalization, bot command metadata, and pi-side command registration behind runtime ports
4
4
  */
5
5
 
6
6
  import { pairTelegramUserIfNeeded } from "./config.ts";
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "./pi.ts";
7
8
  import {
8
9
  createTelegramControlItemBuilder,
9
10
  createTelegramControlQueueController,
@@ -52,22 +53,115 @@ export function createTelegramBotCommandRegistrar(
52
53
  return () => registerTelegramBotCommands(deps);
53
54
  }
54
55
 
56
+ export interface TelegramBridgeCommandStartPollingOptions {
57
+ force?: boolean;
58
+ }
59
+
60
+ export interface TelegramBridgeCommandStartPollingResult {
61
+ ok: boolean;
62
+ message?: string;
63
+ canTakeover?: boolean;
64
+ owner?: string;
65
+ }
66
+
67
+ export interface TelegramBridgeCommandRegistrationDeps {
68
+ promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
69
+ getStatusLines: () => string[];
70
+ reloadConfig: () => Promise<void>;
71
+ hasBotToken: () => boolean;
72
+ startPolling: (
73
+ ctx: ExtensionCommandContext,
74
+ options?: TelegramBridgeCommandStartPollingOptions,
75
+ ) =>
76
+ | void
77
+ | Promise<void | TelegramBridgeCommandStartPollingResult>
78
+ | TelegramBridgeCommandStartPollingResult;
79
+ stopPolling: () => Promise<void | string>;
80
+ updateStatus: (ctx: ExtensionCommandContext) => void;
81
+ }
82
+
83
+ function formatTelegramTakeoverTitle(ctx: ExtensionCommandContext): string {
84
+ return ctx.ui.theme.fg("accent", "pi-telegram");
85
+ }
86
+
87
+ function formatTelegramTakeoverPrompt(
88
+ ctx: ExtensionCommandContext,
89
+ owner?: string,
90
+ ): string {
91
+ const theme = ctx.ui.theme;
92
+ const action = theme.fg("warning", "move singleton lock here?");
93
+ const from = theme.fg("muted", "from:");
94
+ const to = theme.fg("muted", "to:");
95
+ const source = owner ?? "another pi instance";
96
+ return `${action}\n\n${from} ${source}\n${to} ${ctx.cwd}`;
97
+ }
98
+
99
+ export function registerTelegramBridgeCommands(
100
+ pi: ExtensionAPI,
101
+ deps: TelegramBridgeCommandRegistrationDeps,
102
+ ): void {
103
+ pi.registerCommand("telegram-setup", {
104
+ description: "Configure Telegram bot token",
105
+ handler: async (_args, ctx) => {
106
+ await deps.promptForConfig(ctx);
107
+ },
108
+ });
109
+ pi.registerCommand("telegram-status", {
110
+ description: "Show Telegram bridge status",
111
+ handler: async (_args, ctx) => {
112
+ ctx.ui.notify(deps.getStatusLines().join("\n"), "info");
113
+ },
114
+ });
115
+ pi.registerCommand("telegram-connect", {
116
+ description: "Start the Telegram bridge in this pi session",
117
+ handler: async (_args, ctx) => {
118
+ await deps.reloadConfig();
119
+ if (!deps.hasBotToken()) {
120
+ await deps.promptForConfig(ctx);
121
+ return;
122
+ }
123
+ let result = await deps.startPolling(ctx);
124
+ if (result && !result.ok && result.canTakeover) {
125
+ const confirmed = await ctx.ui.confirm(
126
+ formatTelegramTakeoverTitle(ctx),
127
+ formatTelegramTakeoverPrompt(ctx, result.owner),
128
+ );
129
+ if (!confirmed) {
130
+ ctx.ui.notify("Telegram bridge takeover cancelled.", "info");
131
+ deps.updateStatus(ctx);
132
+ return;
133
+ }
134
+ result = await deps.startPolling(ctx, { force: true });
135
+ }
136
+ if (result?.message) {
137
+ ctx.ui.notify(result.message, result.ok ? "info" : "warning");
138
+ }
139
+ deps.updateStatus(ctx);
140
+ },
141
+ });
142
+ pi.registerCommand("telegram-disconnect", {
143
+ description: "Stop the Telegram bridge in this pi session",
144
+ handler: async (_args, ctx) => {
145
+ const message = await deps.stopPolling();
146
+ if (message) ctx.ui.notify(message, "info");
147
+ deps.updateStatus(ctx);
148
+ },
149
+ });
150
+ }
151
+
55
152
  export type TelegramCommandAction =
56
153
  | { kind: "ignore"; executionMode: "ignored" }
57
154
  | { kind: "stop"; executionMode: "immediate" }
58
155
  | { kind: "compact"; executionMode: "immediate" }
59
- | { kind: "status"; executionMode: "control-queue" }
60
- | { kind: "model"; executionMode: "control-queue" }
156
+ | { kind: "status"; executionMode: "immediate" }
157
+ | { kind: "model"; executionMode: "immediate" }
61
158
  | {
62
159
  kind: "help";
63
160
  commandName: "help" | "start";
64
161
  executionMode: "immediate";
65
162
  };
66
163
 
67
- export type TelegramCommandExecutionMode =
68
- | "ignored"
69
- | "immediate"
70
- | "control-queue";
164
+ export type TelegramCommandExecutionMode = "ignored" | "immediate";
71
165
 
72
166
  export interface TelegramCommandActionDeps<TMessage, TContext> {
73
167
  handleStop: (message: TMessage, ctx: TContext) => Promise<void>;
@@ -99,8 +193,7 @@ export interface TelegramRuntimeEventRecorderPort {
99
193
  ) => void;
100
194
  }
101
195
 
102
- export interface TelegramCompactCommandDeps
103
- extends TelegramRuntimeEventRecorderPort {
196
+ export interface TelegramCompactCommandDeps extends TelegramRuntimeEventRecorderPort {
104
197
  isIdle: () => boolean;
105
198
  hasPendingMessages: () => boolean;
106
199
  hasActiveTelegramTurn: () => boolean;
@@ -130,14 +223,6 @@ export interface TelegramHelpCommandDeps {
130
223
  export type TelegramControlCommandType =
131
224
  PendingTelegramControlItem<unknown>["controlType"];
132
225
 
133
- export interface TelegramQueuedControlCommandDeps<TContext> {
134
- enqueueControlItem: (
135
- controlType: TelegramControlCommandType,
136
- statusSummary: string,
137
- execute: (ctx: TContext) => Promise<void>,
138
- ) => void;
139
- }
140
-
141
226
  export interface TelegramCommandRuntimeMessage {
142
227
  chat: { id: number };
143
228
  message_id: number;
@@ -380,9 +465,9 @@ export function buildTelegramCommandAction(
380
465
  case "compact":
381
466
  return { kind: "compact", executionMode: "immediate" };
382
467
  case "status":
383
- return { kind: "status", executionMode: "control-queue" };
468
+ return { kind: "status", executionMode: "immediate" };
384
469
  case "model":
385
- return { kind: "model", executionMode: "control-queue" };
470
+ return { kind: "model", executionMode: "immediate" };
386
471
  case "help":
387
472
  case "start":
388
473
  return { kind: "help", commandName, executionMode: "immediate" };
@@ -483,20 +568,18 @@ export async function handleTelegramHelpCommand(
483
568
  });
484
569
  }
485
570
 
486
- export async function handleTelegramStatusCommand<TContext>(
487
- deps: TelegramQueuedControlCommandDeps<TContext> & {
488
- showStatus: (ctx: TContext) => Promise<void>;
489
- },
490
- ): Promise<void> {
491
- deps.enqueueControlItem("status", "⚡ status", deps.showStatus);
571
+ export async function handleTelegramStatusCommand<TContext>(deps: {
572
+ ctx: TContext;
573
+ showStatus: (ctx: TContext) => Promise<void>;
574
+ }): Promise<void> {
575
+ await deps.showStatus(deps.ctx);
492
576
  }
493
577
 
494
- export async function handleTelegramModelCommand<TContext>(
495
- deps: TelegramQueuedControlCommandDeps<TContext> & {
496
- openModelMenu: (ctx: TContext) => Promise<void>;
497
- },
498
- ): Promise<void> {
499
- deps.enqueueControlItem("model", "⚡ model", deps.openModelMenu);
578
+ export async function handleTelegramModelCommand<TContext>(deps: {
579
+ ctx: TContext;
580
+ openModelMenu: (ctx: TContext) => Promise<void>;
581
+ }): Promise<void> {
582
+ await deps.openModelMenu(deps.ctx);
500
583
  }
501
584
 
502
585
  export async function executeTelegramCommandAction<TMessage, TContext>(
@@ -644,21 +727,6 @@ async function handleTelegramCommandRuntime<
644
727
  deps.sendTextReply(nextMessage, text);
645
728
  const updateStatusFor = (commandCtx: TContext) => () =>
646
729
  deps.updateStatus(commandCtx);
647
- const enqueueControlFor =
648
- (nextMessage: TMessage, commandCtx: TContext) =>
649
- (
650
- controlType: TelegramControlCommandType,
651
- statusSummary: string,
652
- execute: (ctx: TContext) => Promise<void>,
653
- ) => {
654
- deps.enqueueControlItem(
655
- nextMessage,
656
- commandCtx,
657
- controlType,
658
- statusSummary,
659
- execute,
660
- );
661
- };
662
730
  return executeTelegramCommandAction(
663
731
  buildTelegramCommandAction(commandName),
664
732
  message,
@@ -694,13 +762,13 @@ async function handleTelegramCommandRuntime<
694
762
  },
695
763
  handleStatus: async (nextMessage, commandCtx) => {
696
764
  await handleTelegramStatusCommand<TContext>({
697
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
765
+ ctx: commandCtx,
698
766
  showStatus: (controlCtx) => deps.showStatus(nextMessage, controlCtx),
699
767
  });
700
768
  },
701
769
  handleModel: async (nextMessage, commandCtx) => {
702
770
  await handleTelegramModelCommand<TContext>({
703
- enqueueControlItem: enqueueControlFor(nextMessage, commandCtx),
771
+ ctx: commandCtx,
704
772
  openModelMenu: (controlCtx) =>
705
773
  deps.openModelMenu(nextMessage, controlCtx),
706
774
  });
package/lib/handlers.ts CHANGED
@@ -1,31 +1,20 @@
1
1
  /**
2
2
  * Telegram inbound attachment handler pipeline
3
- * Owns MIME/type matching plus command and auto-tool execution for downloaded inbound files before prompt enqueueing
3
+ * Owns MIME/type matching, command-template execution, fallback handling, and prompt injection before prompt enqueueing
4
4
  */
5
5
 
6
- import { readFile } from "node:fs/promises";
7
6
  import { homedir } from "node:os";
8
- import { basename, isAbsolute, join, resolve } from "node:path";
7
+ import { basename, isAbsolute, resolve } from "node:path";
9
8
 
10
9
  const DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS = 120_000;
11
10
 
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
11
  export interface TelegramAttachmentHandlerConfig {
23
12
  match?: string | string[];
24
13
  mime?: string | string[];
25
14
  type?: string | string[];
26
- command?: string;
27
- tool?: string;
28
- args?: Record<string, unknown>;
15
+ template?: string;
16
+ args?: string | string[];
17
+ defaults?: Record<string, unknown>;
29
18
  timeoutMs?: number;
30
19
  }
31
20
 
@@ -77,8 +66,6 @@ export interface TelegramAttachmentHandlerRuntimeDeps<TContext> {
77
66
  options?: TelegramAttachmentHandlerExecOptions,
78
67
  ) => Promise<TelegramAttachmentHandlerExecResult>;
79
68
  getCwd: (ctx: TContext) => string;
80
- readTextFile?: (path: string) => Promise<string>;
81
- autoToolsPath?: string;
82
69
  recordRuntimeEvent?: (
83
70
  category: string,
84
71
  error: unknown,
@@ -99,15 +86,12 @@ interface AttachmentHandlerInvocation {
99
86
  args: string[];
100
87
  }
101
88
 
102
- interface AutoToolConfig {
103
- name: string;
104
- script: string;
105
- args: string[];
106
- }
107
-
108
89
  function normalizeStringList(value: string | string[] | undefined): string[] {
109
90
  if (Array.isArray(value)) {
110
- return value.map(String).map((item) => item.trim()).filter(Boolean);
91
+ return value
92
+ .map(String)
93
+ .map((item) => item.trim())
94
+ .filter(Boolean);
111
95
  }
112
96
  if (typeof value === "string" && value.trim()) return [value.trim()];
113
97
  return [];
@@ -124,7 +108,9 @@ function matchesWildcard(pattern: string, value: string | undefined): boolean {
124
108
  return new RegExp(`^${escaped}$`).test(normalizedValue);
125
109
  }
126
110
 
127
- function handlerHasSelectors(handler: TelegramAttachmentHandlerConfig): boolean {
111
+ function handlerHasSelectors(
112
+ handler: TelegramAttachmentHandlerConfig,
113
+ ): boolean {
128
114
  return (
129
115
  normalizeStringList(handler.match).length > 0 ||
130
116
  normalizeStringList(handler.mime).length > 0 ||
@@ -132,7 +118,10 @@ function handlerHasSelectors(handler: TelegramAttachmentHandlerConfig): boolean
132
118
  );
133
119
  }
134
120
 
135
- function matchesAnyPattern(patterns: string[], value: string | undefined): boolean {
121
+ function matchesAnyPattern(
122
+ patterns: string[],
123
+ value: string | undefined,
124
+ ): boolean {
136
125
  return patterns.some((pattern) => matchesWildcard(pattern, value));
137
126
  }
138
127
 
@@ -150,12 +139,12 @@ export function telegramAttachmentHandlerMatchesFile(
150
139
  return matchesAnyPattern(matchPatterns, file.kind);
151
140
  }
152
141
 
153
- export function findTelegramAttachmentHandler(
142
+ export function findTelegramAttachmentHandlers(
154
143
  handlers: TelegramAttachmentHandlerConfig[] | undefined,
155
144
  file: TelegramAttachmentHandlerFile,
156
- ): TelegramAttachmentHandlerConfig | undefined {
157
- if (!Array.isArray(handlers)) return undefined;
158
- return handlers.find(
145
+ ): TelegramAttachmentHandlerConfig[] {
146
+ if (!Array.isArray(handlers)) return [];
147
+ return handlers.filter(
159
148
  (handler) =>
160
149
  !!handler &&
161
150
  typeof handler === "object" &&
@@ -163,29 +152,64 @@ export function findTelegramAttachmentHandler(
163
152
  );
164
153
  }
165
154
 
166
- function hasAttachmentPlaceholder(value: string): boolean {
167
- return /\{(?:filename|path|basename|mime|type)\}/.test(value);
155
+ export function findTelegramAttachmentHandler(
156
+ handlers: TelegramAttachmentHandlerConfig[] | undefined,
157
+ file: TelegramAttachmentHandlerFile,
158
+ ): TelegramAttachmentHandlerConfig | undefined {
159
+ return findTelegramAttachmentHandlers(handlers, file)[0];
168
160
  }
169
161
 
170
- export function substituteTelegramAttachmentHandlerToken(
171
- token: string,
162
+ function hasAttachmentFilePlaceholder(value: string): boolean {
163
+ return /\{file\}/.test(value);
164
+ }
165
+
166
+ function normalizeTelegramAttachmentHandlerArgs(
167
+ value: string | string[] | undefined,
168
+ ): string[] {
169
+ if (Array.isArray(value)) return value.map(String).map((item) => item.trim());
170
+ if (typeof value !== "string") return [];
171
+ return value.split(",").map((item) => item.trim());
172
+ }
173
+
174
+ function getTelegramAttachmentHandlerArgDefaults(
175
+ handler: TelegramAttachmentHandlerConfig,
176
+ ): Record<string, string> {
177
+ const defaults: Record<string, string> = {};
178
+ for (const item of normalizeTelegramAttachmentHandlerArgs(handler.args)) {
179
+ if (!item) continue;
180
+ const [name, ...defaultParts] = item.split("=");
181
+ if (!name || defaultParts.length === 0) continue;
182
+ defaults[name.trim()] = defaultParts.join("=").trim();
183
+ }
184
+ for (const [key, value] of Object.entries(handler.defaults ?? {})) {
185
+ defaults[key] = value === undefined || value === null ? "" : String(value);
186
+ }
187
+ return defaults;
188
+ }
189
+
190
+ function getTelegramAttachmentHandlerTemplateValues(
191
+ handler: TelegramAttachmentHandlerConfig,
172
192
  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 ?? "",
193
+ ): Record<string, string> {
194
+ return {
195
+ ...getTelegramAttachmentHandlerArgDefaults(handler),
196
+ file: file.path,
197
+ mime: file.mimeType ?? "",
198
+ type: file.kind ?? "",
180
199
  };
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
200
  }
187
201
 
188
- export function splitTelegramAttachmentHandlerCommand(input: string): string[] {
202
+ function substituteTelegramAttachmentHandlerTemplateToken(
203
+ token: string,
204
+ values: Record<string, string>,
205
+ ): string {
206
+ return token.replace(/\{([A-Za-z_][A-Za-z0-9_-]*)\}/g, (_match, name) => {
207
+ if (Object.hasOwn(values, name)) return values[name] ?? "";
208
+ throw new Error(`Missing attachment handler template value: ${name}`);
209
+ });
210
+ }
211
+
212
+ export function splitTelegramAttachmentHandlerTemplate(input: string): string[] {
189
213
  const words: string[] = [];
190
214
  let current = "";
191
215
  let quote: "'" | '"' | undefined;
@@ -237,135 +261,38 @@ function expandExecutablePath(command: string, cwd: string): string {
237
261
  return command;
238
262
  }
239
263
 
240
- export function buildTelegramAttachmentCommandInvocation(
241
- commandTemplate: string,
264
+ function buildTelegramAttachmentTemplateInvocation(
265
+ template: string,
266
+ handler: TelegramAttachmentHandlerConfig,
242
267
  file: TelegramAttachmentHandlerFile,
243
268
  cwd: string,
244
269
  ): AttachmentHandlerInvocation {
245
- const parts = splitTelegramAttachmentHandlerCommand(commandTemplate);
270
+ const parts = splitTelegramAttachmentHandlerTemplate(template);
246
271
  const commandPart = parts[0];
247
- if (!commandPart) throw new Error("Attachment handler command is empty");
248
- const hadPlaceholder = parts.some(hasAttachmentPlaceholder);
272
+ if (!commandPart) throw new Error("Attachment handler template is empty");
273
+ const values = getTelegramAttachmentHandlerTemplateValues(handler, file);
274
+ const hadFilePlaceholder = parts.some(hasAttachmentFilePlaceholder);
249
275
  const command = expandExecutablePath(
250
- substituteTelegramAttachmentHandlerToken(commandPart, file),
276
+ substituteTelegramAttachmentHandlerTemplateToken(commandPart, values),
251
277
  cwd,
252
278
  );
253
279
  const args = parts
254
280
  .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 ?? ""),
281
+ .map((part) =>
282
+ substituteTelegramAttachmentHandlerTemplateToken(part, values),
301
283
  );
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 "";
284
+ if (!hadFilePlaceholder) args.push(file.path);
285
+ return { command, args };
339
286
  }
340
287
 
341
- async function buildTelegramAttachmentToolInvocation(
288
+ export function buildTelegramAttachmentHandlerInvocation(
342
289
  handler: TelegramAttachmentHandlerConfig,
343
290
  file: TelegramAttachmentHandlerFile,
344
291
  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 };
292
+ ): AttachmentHandlerInvocation {
293
+ const { template } = handler;
294
+ if (!template) throw new Error("Attachment handler template is required");
295
+ return buildTelegramAttachmentTemplateInvocation(template, handler, file, cwd);
369
296
  }
370
297
 
371
298
  function getTelegramAttachmentHandlerTimeout(
@@ -378,9 +305,10 @@ function getTelegramAttachmentHandlerTimeout(
378
305
  : DEFAULT_ATTACHMENT_HANDLER_TIMEOUT_MS;
379
306
  }
380
307
 
381
- function getTelegramAttachmentHandlerKind(handler: TelegramAttachmentHandlerConfig): string {
382
- if (handler.command) return "command";
383
- if (handler.tool) return "tool";
308
+ function getTelegramAttachmentHandlerKind(
309
+ handler: TelegramAttachmentHandlerConfig,
310
+ ): string {
311
+ if (handler.template) return "template";
384
312
  return "unknown";
385
313
  }
386
314
 
@@ -399,19 +327,19 @@ async function executeTelegramAttachmentHandler(
399
327
  handler: TelegramAttachmentHandlerConfig,
400
328
  file: TelegramAttachmentHandlerFile,
401
329
  cwd: string,
402
- deps: Pick<
403
- TelegramAttachmentHandlerRuntimeDeps<unknown>,
404
- "execCommand" | "readTextFile" | "autoToolsPath"
405
- >,
330
+ deps: Pick<TelegramAttachmentHandlerRuntimeDeps<unknown>, "execCommand">,
406
331
  ): Promise<string> {
407
- const invocation = handler.command
408
- ? buildTelegramAttachmentCommandInvocation(handler.command, file, cwd)
409
- : await buildTelegramAttachmentToolInvocation(handler, file, cwd, deps);
332
+ const invocation = buildTelegramAttachmentHandlerInvocation(
333
+ handler,
334
+ file,
335
+ cwd,
336
+ );
410
337
  const result = await deps.execCommand(invocation.command, invocation.args, {
411
338
  cwd,
412
339
  timeout: getTelegramAttachmentHandlerTimeout(handler),
413
340
  });
414
- if (result.code !== 0) throw new Error(formatTelegramAttachmentHandlerFailure(result));
341
+ if (result.code !== 0)
342
+ throw new Error(formatTelegramAttachmentHandlerFailure(result));
415
343
  return result.stdout.trim();
416
344
  }
417
345
 
@@ -423,28 +351,28 @@ export async function processTelegramAttachmentHandlers<
423
351
  handlers?: TelegramAttachmentHandlerConfig[];
424
352
  cwd: string;
425
353
  execCommand: TelegramAttachmentHandlerRuntimeDeps<unknown>["execCommand"];
426
- readTextFile?: (path: string) => Promise<string>;
427
- autoToolsPath?: string;
428
354
  recordRuntimeEvent?: TelegramAttachmentHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
429
355
  }): Promise<TelegramAttachmentHandlerProcessResult<TFile>> {
430
356
  const promptFiles: TFile[] = [...options.files];
431
357
  const outputs: TelegramAttachmentHandlerOutput[] = [];
432
358
  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
- });
359
+ const handlers = findTelegramAttachmentHandlers(options.handlers, file);
360
+ for (const handler of handlers) {
361
+ try {
362
+ const output = await executeTelegramAttachmentHandler(
363
+ handler,
364
+ file,
365
+ options.cwd,
366
+ options,
367
+ );
368
+ if (output) outputs.push({ file, output, handler });
369
+ break;
370
+ } catch (error) {
371
+ options.recordRuntimeEvent?.("attachment-handler", error, {
372
+ fileName: file.fileName || basename(file.path),
373
+ handler: getTelegramAttachmentHandlerKind(handler),
374
+ });
375
+ }
448
376
  }
449
377
  }
450
378
  return {
@@ -466,8 +394,6 @@ export function createTelegramAttachmentHandlerRuntime<TContext>(
466
394
  handlers: deps.getHandlers(),
467
395
  cwd: deps.getCwd(ctx),
468
396
  execCommand: deps.execCommand,
469
- readTextFile: deps.readTextFile,
470
- autoToolsPath: deps.autoToolsPath,
471
397
  recordRuntimeEvent: deps.recordRuntimeEvent,
472
398
  }),
473
399
  };