@llblab/pi-telegram 0.7.2 → 0.8.1

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.
@@ -97,6 +97,55 @@ export interface TelegramVoiceReplySenderDeps {
97
97
  ) => void;
98
98
  }
99
99
 
100
+ export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
101
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
102
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
103
+ sendTextReply: (
104
+ chatId: number,
105
+ replyToMessageId: number | undefined,
106
+ text: string,
107
+ options?: { parseMode?: "HTML" },
108
+ ) => Promise<number | undefined>;
109
+ sendMarkdownReply: (
110
+ chatId: number,
111
+ replyToMessageId: number | undefined,
112
+ markdown: string,
113
+ options?: { replyMarkup?: TReplyMarkup },
114
+ ) => Promise<number | undefined>;
115
+ cwd?: string;
116
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
117
+ }
118
+
119
+ export interface TelegramInlineKeyboardLike {
120
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
121
+ }
122
+
123
+ export interface TelegramOutboundTextTransformOptions<TReplyMarkup = unknown> {
124
+ handlers?: TelegramOutboundHandlerConfig[];
125
+ cwd?: string;
126
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
127
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
128
+ replyMarkup?: TReplyMarkup;
129
+ }
130
+
131
+ export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
132
+ text: string;
133
+ replyMarkup?: TReplyMarkup;
134
+ }
135
+
136
+ export interface TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup = unknown> {
137
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
138
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
139
+ finalizeMarkdownPreview: (
140
+ chatId: number,
141
+ markdown: string,
142
+ replyToMessageId: number,
143
+ options?: { replyMarkup?: TReplyMarkup },
144
+ ) => Promise<boolean>;
145
+ cwd?: string;
146
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
147
+ }
148
+
100
149
  interface TelegramTopLevelHtmlComment {
101
150
  raw: string;
102
151
  content: string;
@@ -601,7 +650,7 @@ async function generateTelegramVoiceReplyFileWithHandler(
601
650
  const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
602
651
  if (steps.length > 0) {
603
652
  const startedAt = Date.now();
604
- let stdout = "";
653
+ let stdout = text;
605
654
  for (const [index, step] of steps.entries()) {
606
655
  try {
607
656
  const result = await runVoiceReplyCommand(
@@ -616,7 +665,7 @@ async function generateTelegramVoiceReplyFileWithHandler(
616
665
  startedAt,
617
666
  ),
618
667
  execCommand: options.execCommand,
619
- ...(index === 0 ? {} : { stdin: stdout }),
668
+ stdin: stdout,
620
669
  },
621
670
  );
622
671
  stdout = result.stdout;
@@ -665,6 +714,202 @@ export async function generateTelegramVoiceReplyFile(
665
714
  });
666
715
  }
667
716
 
717
+ function getOutboundTextTemplateValues(text: string): Record<string, string> {
718
+ return { text, type: "text" };
719
+ }
720
+
721
+ async function transformTelegramOutboundTextWithHandler(
722
+ text: string,
723
+ options: {
724
+ handler: TelegramOutboundHandlerConfig;
725
+ cwd: string;
726
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
727
+ },
728
+ ): Promise<string> {
729
+ const values = getOutboundTextTemplateValues(text);
730
+ const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
731
+ if (steps.length > 0) {
732
+ const startedAt = Date.now();
733
+ let stdout = text;
734
+ for (const [index, step] of steps.entries()) {
735
+ try {
736
+ const result = await runVoiceReplyCommand(
737
+ `Outbound text template step ${index + 1}`,
738
+ step,
739
+ values,
740
+ {
741
+ cwd: options.cwd,
742
+ timeout: getVoiceReplyCompositionStepTimeout(
743
+ getVoiceReplyTimeout(options.handler),
744
+ step,
745
+ startedAt,
746
+ ),
747
+ execCommand: options.execCommand,
748
+ stdin: stdout,
749
+ },
750
+ );
751
+ stdout = result.stdout;
752
+ } catch (error) {
753
+ if (typeof step === "object" && step.critical) throw error;
754
+ stdout = "";
755
+ }
756
+ if (!stdout) stdout = text;
757
+ }
758
+ return stdout.trim() || text;
759
+ }
760
+ const result = await runVoiceReplyCommand(
761
+ "Outbound text template",
762
+ options.handler,
763
+ values,
764
+ {
765
+ cwd: options.cwd,
766
+ timeout: getVoiceReplyTimeout(options.handler),
767
+ execCommand: options.execCommand,
768
+ stdin: text,
769
+ },
770
+ );
771
+ return result.stdout.trim() || text;
772
+ }
773
+
774
+ export async function transformTelegramOutboundText(
775
+ text: string,
776
+ options: {
777
+ handlers?: TelegramOutboundHandlerConfig[];
778
+ cwd?: string;
779
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
780
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
781
+ },
782
+ ): Promise<string> {
783
+ let transformed = text;
784
+ for (const handler of findTelegramOutboundHandlers(options.handlers, "text")) {
785
+ try {
786
+ transformed = await transformTelegramOutboundTextWithHandler(
787
+ transformed,
788
+ {
789
+ handler,
790
+ cwd: options.cwd ?? process.cwd(),
791
+ execCommand: options.execCommand,
792
+ },
793
+ );
794
+ } catch (error) {
795
+ options.recordRuntimeEvent?.("outbound-text-handler", error, {
796
+ handler: outboundHandlerMatchesType(handler, "text") ? "text" : "unknown",
797
+ });
798
+ }
799
+ }
800
+ return transformed;
801
+ }
802
+
803
+ function isTelegramInlineKeyboardLike(
804
+ replyMarkup: unknown,
805
+ ): replyMarkup is TelegramInlineKeyboardLike {
806
+ if (!replyMarkup || typeof replyMarkup !== "object") return false;
807
+ const keyboard = (replyMarkup as { inline_keyboard?: unknown }).inline_keyboard;
808
+ return Array.isArray(keyboard);
809
+ }
810
+
811
+ async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
812
+ replyMarkup: TReplyMarkup | undefined,
813
+ options: Omit<TelegramOutboundTextTransformOptions, "replyMarkup">,
814
+ ): Promise<TReplyMarkup | undefined> {
815
+ if (!isTelegramInlineKeyboardLike(replyMarkup)) return replyMarkup;
816
+ const translatedRows = [];
817
+ for (const row of replyMarkup.inline_keyboard) {
818
+ const translatedRow = [];
819
+ for (const button of row) {
820
+ const text = await transformTelegramOutboundText(button.text, options);
821
+ translatedRow.push({ ...button, text });
822
+ }
823
+ translatedRows.push(translatedRow);
824
+ }
825
+ return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
826
+ }
827
+
828
+ export async function transformTelegramOutboundTextReply<TReplyMarkup = unknown>(
829
+ text: string,
830
+ options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
831
+ ): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
832
+ const transformOptions = {
833
+ handlers: options.handlers,
834
+ cwd: options.cwd,
835
+ execCommand: options.execCommand,
836
+ recordRuntimeEvent: options.recordRuntimeEvent,
837
+ };
838
+ const transformedText = await transformTelegramOutboundText(
839
+ text,
840
+ transformOptions,
841
+ );
842
+ const replyMarkup = await transformTelegramOutboundReplyMarkup(
843
+ options.replyMarkup,
844
+ transformOptions,
845
+ );
846
+ return { text: transformedText, ...(replyMarkup ? { replyMarkup } : {}) };
847
+ }
848
+
849
+ export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
850
+ deps: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
851
+ ): Pick<
852
+ TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
853
+ "sendTextReply" | "sendMarkdownReply"
854
+ > {
855
+ return {
856
+ sendTextReply: async (chatId, replyToMessageId, text, options) => {
857
+ const transformed = await transformTelegramOutboundText(text, {
858
+ handlers: deps.getHandlers?.(),
859
+ cwd: deps.cwd,
860
+ execCommand: deps.execCommand,
861
+ recordRuntimeEvent: deps.recordRuntimeEvent,
862
+ });
863
+ return deps.sendTextReply(chatId, replyToMessageId, transformed, options);
864
+ },
865
+ sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
866
+ const transformed = await transformTelegramOutboundTextReply(markdown, {
867
+ handlers: deps.getHandlers?.(),
868
+ cwd: deps.cwd,
869
+ execCommand: deps.execCommand,
870
+ recordRuntimeEvent: deps.recordRuntimeEvent,
871
+ replyMarkup: options?.replyMarkup,
872
+ });
873
+ return deps.sendMarkdownReply(chatId, replyToMessageId, transformed.text, {
874
+ ...options,
875
+ ...(transformed.replyMarkup
876
+ ? { replyMarkup: transformed.replyMarkup }
877
+ : {}),
878
+ });
879
+ },
880
+ };
881
+ }
882
+
883
+ export function createTelegramOutboundTextPreviewRuntime<TReplyMarkup = unknown>(
884
+ deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
885
+ ): Pick<
886
+ TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
887
+ "finalizeMarkdownPreview"
888
+ > {
889
+ return {
890
+ finalizeMarkdownPreview: async (chatId, markdown, replyToMessageId, options) => {
891
+ const transformed = await transformTelegramOutboundTextReply(markdown, {
892
+ handlers: deps.getHandlers?.(),
893
+ cwd: deps.cwd,
894
+ execCommand: deps.execCommand,
895
+ recordRuntimeEvent: deps.recordRuntimeEvent,
896
+ replyMarkup: options?.replyMarkup,
897
+ });
898
+ return deps.finalizeMarkdownPreview(
899
+ chatId,
900
+ transformed.text,
901
+ replyToMessageId,
902
+ {
903
+ ...options,
904
+ ...(transformed.replyMarkup
905
+ ? { replyMarkup: transformed.replyMarkup }
906
+ : {}),
907
+ },
908
+ );
909
+ },
910
+ };
911
+ }
912
+
668
913
  export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
669
914
  markdown: string;
670
915
  replyMarkup?: TReplyMarkup;
package/lib/prompts.ts CHANGED
@@ -14,7 +14,7 @@ Telegram bridge extension is active.
14
14
  Inbound context:
15
15
  - \`[telegram]\` marks Telegram-originated messages.
16
16
  - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
17
- - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains attachment-handler stdout such as transcriptions or extracted text for those attachments.
17
+ - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
18
18
  - Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
19
19
 
20
20
  Telegram-visible output:
package/lib/queue.ts CHANGED
@@ -78,6 +78,7 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
78
78
  queuedAttachments: QueuedAttachment[];
79
79
  content: TelegramPromptContent[];
80
80
  historyText: string;
81
+ priorityEmoji?: string;
81
82
  }
82
83
 
83
84
  export interface PendingTelegramControlItem<
@@ -304,6 +305,7 @@ export function clearTelegramQueuePromptPriority<TContext = unknown>(
304
305
  ...item,
305
306
  queueLane: "default" as const,
306
307
  laneOrder: item.queueOrder,
308
+ priorityEmoji: undefined,
307
309
  };
308
310
  });
309
311
  return { items: nextItems, changed };
@@ -313,6 +315,7 @@ export function prioritizeTelegramQueuePrompt<TContext = unknown>(
313
315
  items: TelegramQueueItem<TContext>[],
314
316
  messageId: number,
315
317
  laneOrder: number,
318
+ priorityEmoji = "⚡",
316
319
  ): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
317
320
  let changed = false;
318
321
  const nextItems = items.map((item) => {
@@ -327,6 +330,7 @@ export function prioritizeTelegramQueuePrompt<TContext = unknown>(
327
330
  ...item,
328
331
  queueLane: "priority" as const,
329
332
  laneOrder,
333
+ priorityEmoji,
330
334
  };
331
335
  });
332
336
  return { items: nextItems, changed };
@@ -353,7 +357,7 @@ function formatTelegramQueueItemStatusSummary<TContext = unknown>(
353
357
  item: TelegramQueueItem<TContext>,
354
358
  ): string {
355
359
  if (item.queueLane === "priority") {
356
- return `⚡ ${item.statusSummary}`;
360
+ return `${item.kind === "prompt" ? item.priorityEmoji ?? "⚡" : "⚡"} ${item.statusSummary}`;
357
361
  }
358
362
  return item.statusSummary;
359
363
  }
@@ -1161,7 +1165,11 @@ export interface TelegramQueueMutationController<TContext> {
1161
1165
  clear: (ctx: TContext) => number;
1162
1166
  removeByMessageIds: (messageIds: number[], ctx: TContext) => number;
1163
1167
  clearPriorityByMessageId: (messageId: number, ctx: TContext) => boolean;
1164
- prioritizeByMessageId: (messageId: number, ctx: TContext) => boolean;
1168
+ prioritizeByMessageId: (
1169
+ messageId: number,
1170
+ ctx: TContext,
1171
+ priorityEmoji?: string,
1172
+ ) => boolean;
1165
1173
  }
1166
1174
 
1167
1175
  export interface TelegramControlQueueControllerDeps<TContext> {
@@ -1375,8 +1383,12 @@ export function createTelegramQueueMutationController<TContext>(
1375
1383
  ),
1376
1384
  clearPriorityByMessageId: (messageId, ctx) =>
1377
1385
  clearTelegramQueuePromptPriorityRuntime(messageId, buildRuntimeDeps(ctx)),
1378
- prioritizeByMessageId: (messageId, ctx) =>
1379
- prioritizeTelegramQueuePromptRuntime(messageId, buildRuntimeDeps(ctx)),
1386
+ prioritizeByMessageId: (messageId, ctx, priorityEmoji) =>
1387
+ prioritizeTelegramQueuePromptRuntime(
1388
+ messageId,
1389
+ buildRuntimeDeps(ctx),
1390
+ priorityEmoji,
1391
+ ),
1380
1392
  };
1381
1393
  }
1382
1394
 
@@ -1438,6 +1450,7 @@ export function clearTelegramQueuePromptPriorityRuntime<TContext>(
1438
1450
  export function prioritizeTelegramQueuePromptRuntime<TContext>(
1439
1451
  messageId: number,
1440
1452
  deps: TelegramQueueMutationRuntimeDeps<TContext>,
1453
+ priorityEmoji?: string,
1441
1454
  ): boolean {
1442
1455
  const nextPriorityReactionOrder = deps.getNextPriorityReactionOrder?.();
1443
1456
  if (nextPriorityReactionOrder === undefined) return false;
@@ -1445,6 +1458,7 @@ export function prioritizeTelegramQueuePromptRuntime<TContext>(
1445
1458
  deps.getQueuedItems(),
1446
1459
  messageId,
1447
1460
  nextPriorityReactionOrder,
1461
+ priorityEmoji,
1448
1462
  );
1449
1463
  if (!changed) return false;
1450
1464
  deps.setQueuedItems(items);
package/lib/routing.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import * as OutboundHandlers from "./outbound-handlers.ts";
8
8
  import * as Commands from "./commands.ts";
9
9
  import type { TelegramConfigStore } from "./config.ts";
10
- import type { TelegramAttachmentHandlerRuntime } from "./attachment-handlers.ts";
10
+ import type { TelegramInboundHandlerRuntime } from "./inbound-handlers.ts";
11
11
  import * as Media from "./media.ts";
12
12
  import * as Menu from "./menu.ts";
13
13
  import * as Model from "./model.ts";
@@ -60,7 +60,7 @@ export interface TelegramInboundRouteRuntimeDeps<
60
60
  ctx: TContext,
61
61
  ) => Promise<boolean>;
62
62
  buttonActionStore?: OutboundHandlers.TelegramButtonActionStore;
63
- attachmentHandlerRuntime: TelegramAttachmentHandlerRuntime<TContext>;
63
+ inboundHandlerRuntime: TelegramInboundHandlerRuntime<TContext>;
64
64
  updateStatus: (ctx: TContext, error?: string) => void;
65
65
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
66
66
  answerCallbackQuery: (
@@ -205,7 +205,7 @@ export function createTelegramInboundRouteRuntime<
205
205
  >({
206
206
  allocateQueueOrder: deps.bridgeRuntime.queue.allocateItemOrder,
207
207
  downloadFile: deps.downloadFile,
208
- processAttachments: deps.attachmentHandlerRuntime.process,
208
+ processAttachments: deps.inboundHandlerRuntime.process,
209
209
  });
210
210
  const enqueueContinueTurn = async (
211
211
  message: TMessage,
package/lib/updates.ts CHANGED
@@ -26,18 +26,23 @@ export type TelegramReactionType =
26
26
  | TelegramReactionTypeEmoji
27
27
  | TelegramReactionTypeNonEmoji;
28
28
 
29
- export const TELEGRAM_PRIORITY_REACTION_EMOJIS = [
30
- "👍",
31
- "⚡",
32
- "❤",
33
- "🕊",
29
+ export const TELEGRAM_PRIORITY_REACTIONS = [
30
+ { id: 10, name: "like", emoji: "👍" },
31
+ { id: 11, name: "lightning", emoji: "⚡" },
32
+ { id: 12, name: "heart", emoji: "❤" },
33
+ { id: 13, name: "dove", emoji: "🕊" },
34
34
  ] as const;
35
- export const TELEGRAM_REMOVAL_REACTION_EMOJIS = [
36
- "👎",
37
- "👻",
38
- "💔",
39
- "💩",
35
+ export const TELEGRAM_REMOVAL_REACTIONS = [
36
+ { id: 20, name: "dislike", emoji: "👎" },
37
+ { id: 21, name: "ghost", emoji: "👻" },
38
+ { id: 22, name: "broken-heart", emoji: "💔" },
39
+ { id: 23, name: "poop", emoji: "💩" },
40
40
  ] as const;
41
+ export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
42
+ TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
43
+ export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map(
44
+ (reaction) => reaction.emoji,
45
+ );
41
46
 
42
47
  export interface TelegramUpdateDeletion {
43
48
  deleted_business_messages?: { message_ids?: unknown };
@@ -71,15 +76,22 @@ function hasAnyTelegramReactionEmoji(
71
76
  return candidates.some((emoji) => emojis.has(emoji));
72
77
  }
73
78
 
74
- function hasAddedTelegramReactionEmoji(
79
+ function getAddedTelegramReactionEmoji(
75
80
  oldEmojis: Set<string>,
76
81
  newEmojis: Set<string>,
77
82
  candidates: readonly string[],
78
- ): boolean {
79
- return candidates.some(
83
+ ): string | undefined {
84
+ return candidates.find(
80
85
  (emoji) => !oldEmojis.has(emoji) && newEmojis.has(emoji),
81
86
  );
82
87
  }
88
+ function hasAddedTelegramReactionEmoji(
89
+ oldEmojis: Set<string>,
90
+ newEmojis: Set<string>,
91
+ candidates: readonly string[],
92
+ ): boolean {
93
+ return !!getAddedTelegramReactionEmoji(oldEmojis, newEmojis, candidates);
94
+ }
83
95
 
84
96
  export function extractDeletedTelegramMessageIds(
85
97
  update: TelegramUpdateDeletion,
@@ -410,6 +422,7 @@ export interface TelegramUpdateRuntimeControllerDeps<
410
422
  prioritizeQueuedTelegramTurnByMessageId: (
411
423
  messageId: number,
412
424
  ctx: TContext,
425
+ priorityEmoji?: string,
413
426
  ) => boolean;
414
427
  pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
415
428
  answerCallbackQuery: (
@@ -592,6 +605,7 @@ export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
592
605
  prioritizeQueuedTelegramTurnByMessageId: (
593
606
  messageId: number,
594
607
  ctx: TContext,
608
+ priorityEmoji?: string,
595
609
  ) => boolean;
596
610
  }
597
611
 
@@ -638,17 +652,16 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
638
652
  deps.ctx,
639
653
  );
640
654
  }
641
- if (
642
- !hasAddedTelegramReactionEmoji(
643
- oldEmojis,
644
- newEmojis,
645
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
646
- )
647
- )
648
- return;
655
+ const addedPriorityEmoji = getAddedTelegramReactionEmoji(
656
+ oldEmojis,
657
+ newEmojis,
658
+ TELEGRAM_PRIORITY_REACTION_EMOJIS,
659
+ );
660
+ if (!addedPriorityEmoji) return;
649
661
  deps.prioritizeQueuedTelegramTurnByMessageId(
650
662
  reactionUpdate.message_id,
651
663
  deps.ctx,
664
+ addedPriorityEmoji,
652
665
  );
653
666
  }
654
667
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for π",
6
6
  "type": "module",
@@ -1,50 +0,0 @@
1
- # Attachment Handlers
2
-
3
- `pi-telegram` can run ordered inbound attachment handlers after downloading files and before the Telegram turn enters the π queue.
4
-
5
- This document is the local adaptation of the portable [Command Template Standard](./command-templates.md).
6
-
7
- ## Config Shape
8
-
9
- `telegram.json` may define `attachmentHandlers`:
10
-
11
- ```json
12
- {
13
- "attachmentHandlers": [
14
- {
15
- "type": "voice",
16
- "template": "/path/to/stt1 --file {file} --lang {lang=ru}"
17
- },
18
- {
19
- "mime": "audio/*",
20
- "template": "/path/to/stt2 --file {file} --lang {lang=ru}"
21
- }
22
- ]
23
- }
24
- ```
25
-
26
- Handlers match by `type`, `mime`, or `match`. Wildcards such as `audio/*` are accepted. Each matching handler must provide `template`; a string is one command, and an array is ordered composition. Top-level `args` and `defaults` apply to composed steps unless a step defines private values. The command-template default timeout applies automatically. Legacy configs may still use `pipe` as a local alias.
27
-
28
- ## Template Placeholders
29
-
30
- Attachment handlers support these built-in placeholders:
31
-
32
- | Placeholder | Value |
33
- | ----------- | ---------------------------------------------------------------- |
34
- | `{file}` | Full local path to the downloaded file |
35
- | `{mime}` | MIME type if known |
36
- | `{type}` | Attachment kind such as `voice`, `audio`, `document`, or `photo` |
37
-
38
- `defaults` may provide additional placeholder values such as `{lang}` or `{model}`. `args` is only a string-array declaration of supported placeholders; defaults belong in `defaults` or inline placeholders such as `{lang=ru}`. Examples prefer explicit flag-style CLIs for readability, but positional forms such as `/path/to/stt {file} {lang=ru} {model=voxtral-mini-latest}` are equally valid when the target script supports them.
39
-
40
- If a top-level one-step handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg as a one-step handler convenience. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
41
-
42
- ## Ordered Fallbacks
43
-
44
- A handler list is ordered. For each attachment, matching handlers run in list order and stop after the first successful handler. A composed handler counts as one handler for fallback purposes: if any step fails, the next matching handler is tried.
45
-
46
- If a matching handler fails with a non-zero exit code, the runtime records diagnostics and tries the next matching handler. If every matching handler fails, the attachment remains visible in the prompt as a normal local file reference.
47
-
48
- ## Prompt Output
49
-
50
- Local attachments stay in the prompt under `[attachments] <directory>` with relative file entries. Successful handler stdout is added under `[outputs]`. For composed handlers, each step receives the previous step's stdout on stdin by default, and stdout from the last successful step is used as the handler output. Empty output and failed handler output are omitted from the prompt text.