@llblab/pi-telegram 0.11.2 → 0.13.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 (53) hide show
  1. package/AGENTS.md +20 -15
  2. package/BACKLOG.md +1 -11
  3. package/CHANGELOG.md +41 -1
  4. package/README.md +15 -41
  5. package/api/inbound.ts +14 -0
  6. package/api/keyboard.ts +10 -0
  7. package/api/outbound.ts +11 -0
  8. package/api/sections.ts +17 -0
  9. package/api/updates.ts +11 -0
  10. package/api/voice.ts +24 -0
  11. package/docs/README.md +7 -5
  12. package/docs/architecture.md +162 -226
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +18 -16
  15. package/docs/{inbound-handlers.md → inbound.md} +14 -11
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +14 -11
  18. package/docs/public-api.md +420 -0
  19. package/docs/{extension-sections.md → sections.md} +34 -30
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +27 -19
  23. package/index.ts +88 -242
  24. package/lib/bindings.ts +299 -0
  25. package/lib/command-templates.ts +249 -60
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +44 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +31 -21
  29. package/lib/lifecycle.ts +41 -6
  30. package/lib/locks.ts +4 -1
  31. package/lib/menu-model.ts +3 -3
  32. package/lib/menu-queue.ts +1 -1
  33. package/lib/menu-settings.ts +21 -10
  34. package/lib/menu-status.ts +1 -1
  35. package/lib/menu.ts +1 -1
  36. package/lib/outbound-buttons.ts +226 -0
  37. package/lib/outbound-markup.ts +357 -0
  38. package/lib/outbound-voice.ts +263 -0
  39. package/lib/outbound.ts +908 -0
  40. package/lib/polling.ts +4 -3
  41. package/lib/preview.ts +2 -2
  42. package/lib/queue.ts +3 -0
  43. package/lib/replies.ts +4 -1
  44. package/lib/routing.ts +44 -3
  45. package/lib/{extension-sections.ts → sections.ts} +37 -8
  46. package/lib/status.ts +13 -0
  47. package/lib/{api.ts → telegram-api.ts} +4 -4
  48. package/lib/text-groups.ts +3 -2
  49. package/lib/updates.ts +121 -1
  50. package/lib/voice.ts +67 -21
  51. package/package.json +13 -3
  52. package/lib/external-handlers.ts +0 -166
  53. package/lib/outbound-handlers.ts +0 -1663
@@ -0,0 +1,908 @@
1
+ /**
2
+ * Telegram outbound surface helpers
3
+ * Zones: telegram outbound, command templates, voice delivery
4
+ * Owns configured outbound handler execution, text transforms, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
5
+ */
6
+
7
+ import { randomUUID } from "node:crypto";
8
+ import { mkdir } from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+
12
+ import {
13
+ planTelegramButtonReply,
14
+ type TelegramButtonActionStore,
15
+ type TelegramOutboundButtonMarkup,
16
+ } from "./outbound-buttons.ts";
17
+ import {
18
+ planTelegramVoiceReply,
19
+ type TelegramVoiceReplyItem,
20
+ } from "./outbound-markup.ts";
21
+ import { createTelegramVoiceReplySender as createTelegramVoiceReplySenderWithPorts } from "./outbound-voice.ts";
22
+
23
+ const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
24
+ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
25
+
26
+ import {
27
+ buildCommandTemplateInvocation,
28
+ expandCommandTemplateConfigs,
29
+ substituteCommandTemplateToken,
30
+ type CommandTemplateObjectConfig,
31
+ } from "./command-templates.ts";
32
+ const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
33
+
34
+ // --- Types ---
35
+
36
+ /**
37
+ * Record a runtime event that appears in `/telegram-status`.
38
+ * Voice synthesis provider extensions can call this to surface diagnostics
39
+ * alongside pi-telegram's own events. Events are silently dropped
40
+ * when pi-telegram is not loaded.
41
+ */
42
+ export type TelegramRuntimeEventRecorder = (
43
+ category: string,
44
+ error: unknown,
45
+ details?: Record<string, unknown>,
46
+ ) => void;
47
+
48
+ export function bindTelegramRuntimeEventRecorder(
49
+ recorder: TelegramRuntimeEventRecorder,
50
+ ): void {
51
+ (globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] = recorder;
52
+ }
53
+
54
+ export function recordTelegramRuntimeEvent(
55
+ category: string,
56
+ error: unknown,
57
+ details?: Record<string, unknown>,
58
+ ): void {
59
+ const recorder = (globalThis as Record<string, unknown>)[
60
+ VOICE_EVENT_RECORDER_KEY
61
+ ];
62
+ if (typeof recorder === "function") {
63
+ (recorder as TelegramRuntimeEventRecorder)(category, error, details);
64
+ }
65
+ }
66
+
67
+ export type TelegramOutboundCommandTemplateConfig =
68
+ | string
69
+ | CommandTemplateObjectConfig;
70
+ export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
71
+ type?: string;
72
+ match?: string | string[];
73
+ output?: string;
74
+ timeout?: number | string;
75
+ }
76
+
77
+ export {
78
+ normalizeMarkdownAfterVoiceExtraction,
79
+ planTelegramVoiceReply,
80
+ stripTelegramCommentMarkupForDelivery,
81
+ stripTelegramCommentMarkupForPreview,
82
+ stripTelegramVoiceMarkupForPreview,
83
+ type TelegramVoiceReplyItem,
84
+ type TelegramVoiceReplyPlan,
85
+ } from "./outbound-markup.ts";
86
+
87
+ export interface TelegramVoiceExecOptions {
88
+ cwd?: string;
89
+ timeout?: number;
90
+ signal?: AbortSignal;
91
+ stdin?: string;
92
+ retry?: number;
93
+ }
94
+
95
+ export interface TelegramVoiceExecResult {
96
+ stdout: string;
97
+ stderr: string;
98
+ code: number;
99
+ killed: boolean;
100
+ }
101
+
102
+ export interface TelegramVoiceReplyTurnView {
103
+ chatId: number;
104
+ replyToMessageId: number;
105
+ }
106
+
107
+ export interface TelegramVoiceReplySenderDeps {
108
+ execCommand: (
109
+ command: string,
110
+ args: string[],
111
+ options?: TelegramVoiceExecOptions,
112
+ ) => Promise<TelegramVoiceExecResult>;
113
+ sendMultipart: (
114
+ method: string,
115
+ fields: Record<string, string>,
116
+ fileField: string,
117
+ filePath: string,
118
+ fileName: string,
119
+ ) => Promise<unknown>;
120
+ sendTextReply?: (
121
+ chatId: number,
122
+ replyToMessageId: number | undefined,
123
+ text: string,
124
+ options?: { parseMode?: "HTML" },
125
+ ) => Promise<unknown>;
126
+ sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
127
+ sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
128
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
129
+ cwd?: string;
130
+ tempDir?: string;
131
+ recordRuntimeEvent?: (
132
+ category: string,
133
+ error: unknown,
134
+ details?: Record<string, unknown>,
135
+ ) => void;
136
+ }
137
+
138
+ // --- Programmatic Outbound Handler Registry ---
139
+
140
+ export type TelegramOutboundProgrammaticHandler = (
141
+ text: string,
142
+ options?: { lang?: string; rate?: string },
143
+ ) => Promise<string>;
144
+
145
+ export interface TelegramOutboundHandlerRegistry {
146
+ handlers: Map<string, TelegramOutboundProgrammaticHandler[]>;
147
+ }
148
+
149
+ // --- Programmatic Outbound Handler Registry Runtime ---
150
+
151
+ function getOrCreateOutboundHandlerRegistry(): TelegramOutboundHandlerRegistry {
152
+ const existing = (globalThis as Record<string, unknown>)[
153
+ OUTBOUND_HANDLER_REGISTRY_KEY
154
+ ];
155
+ if (
156
+ existing &&
157
+ typeof existing === "object" &&
158
+ existing !== null &&
159
+ "handlers" in existing &&
160
+ existing.handlers instanceof Map
161
+ ) {
162
+ return existing as TelegramOutboundHandlerRegistry;
163
+ }
164
+ const registry: TelegramOutboundHandlerRegistry = {
165
+ handlers: new Map(),
166
+ };
167
+ (globalThis as Record<string, unknown>)[OUTBOUND_HANDLER_REGISTRY_KEY] =
168
+ registry;
169
+ return registry;
170
+ }
171
+
172
+ export function registerTelegramOutboundHandler(
173
+ kind: string,
174
+ handler: TelegramOutboundProgrammaticHandler,
175
+ ): () => void {
176
+ const registry = getOrCreateOutboundHandlerRegistry();
177
+ const list = registry.handlers.get(kind) ?? [];
178
+ list.push(handler);
179
+ registry.handlers.set(kind, list);
180
+ return () => {
181
+ const updated = registry.handlers.get(kind) ?? [];
182
+ const index = updated.indexOf(handler);
183
+ if (index !== -1) {
184
+ updated.splice(index, 1);
185
+ registry.handlers.set(kind, updated);
186
+ }
187
+ };
188
+ }
189
+
190
+ export function hasTelegramOutboundHandler(kind: string): boolean {
191
+ const registry = getOrCreateOutboundHandlerRegistry();
192
+ const list = registry.handlers.get(kind);
193
+ return !!list && list.length > 0;
194
+ }
195
+
196
+ export function getTelegramOutboundProgrammaticHandlers(
197
+ kind: string,
198
+ ): TelegramOutboundProgrammaticHandler[] {
199
+ const registry = getOrCreateOutboundHandlerRegistry();
200
+ return [...(registry.handlers.get(kind) ?? [])];
201
+ }
202
+
203
+ export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
204
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
205
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
206
+ sendTextReply: (
207
+ chatId: number,
208
+ replyToMessageId: number | undefined,
209
+ text: string,
210
+ options?: { parseMode?: "HTML" },
211
+ ) => Promise<number | undefined>;
212
+ sendMarkdownReply: (
213
+ chatId: number,
214
+ replyToMessageId: number | undefined,
215
+ markdown: string,
216
+ options?: { replyMarkup?: TReplyMarkup },
217
+ ) => Promise<number | undefined>;
218
+ cwd?: string;
219
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
220
+ }
221
+
222
+ export interface TelegramInlineKeyboardLike {
223
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
224
+ }
225
+
226
+ export interface TelegramOutboundTextTransformOptions<TReplyMarkup = unknown> {
227
+ handlers?: TelegramOutboundHandlerConfig[];
228
+ cwd?: string;
229
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
230
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
231
+ replyMarkup?: TReplyMarkup;
232
+ }
233
+
234
+ export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
235
+ text: string;
236
+ replyMarkup?: TReplyMarkup;
237
+ }
238
+
239
+ export interface TelegramOutboundTextPreviewRuntimeDeps<
240
+ TReplyMarkup = unknown,
241
+ > {
242
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
243
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
244
+ finalizeMarkdownPreview: (
245
+ chatId: number,
246
+ markdown: string,
247
+ replyToMessageId: number,
248
+ options?: { replyMarkup?: TReplyMarkup },
249
+ ) => Promise<boolean>;
250
+ cwd?: string;
251
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
252
+ }
253
+
254
+ // --- Voice Reply Timeout Helpers ---
255
+
256
+ function resolveOutboundNumericControlField(
257
+ value: number | string | undefined,
258
+ values: Record<string, unknown>,
259
+ label: string,
260
+ ): number | undefined {
261
+ if (value === undefined) return undefined;
262
+ const resolved =
263
+ typeof value === "string"
264
+ ? substituteCommandTemplateToken(value, values, label)
265
+ : value;
266
+ if (resolved === "") return undefined;
267
+ const numeric = Number(resolved);
268
+ if (!Number.isFinite(numeric) || numeric < 0)
269
+ throw new Error(`Command template ${label} must be a non-negative number.`);
270
+ return numeric;
271
+ }
272
+
273
+ function getVoiceReplyConfiguredTimeout(
274
+ config: TelegramOutboundCommandTemplateConfig | undefined,
275
+ ): number | undefined {
276
+ const timeout = typeof config === "string" ? undefined : config?.timeout;
277
+ return resolveOutboundNumericControlField(timeout, {}, "timeout");
278
+ }
279
+
280
+ function getVoiceReplyTimeout(
281
+ config: TelegramOutboundCommandTemplateConfig | undefined,
282
+ ): number {
283
+ return getVoiceReplyConfiguredTimeout(config) ?? DEFAULT_VOICE_TIMEOUT_MS;
284
+ }
285
+
286
+ function getRemainingVoiceReplyTimeout(
287
+ timeout: number,
288
+ startedAt: number,
289
+ ): number {
290
+ return Math.max(1, timeout - (Date.now() - startedAt));
291
+ }
292
+
293
+ function getVoiceReplyCompositionStepTimeout(
294
+ handlerTimeout: number,
295
+ step: TelegramOutboundCommandTemplateConfig,
296
+ startedAt: number,
297
+ ): number {
298
+ const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
299
+ const stepTimeout = getVoiceReplyConfiguredTimeout(step);
300
+ return stepTimeout === undefined
301
+ ? remaining
302
+ : Math.min(stepTimeout, remaining);
303
+ }
304
+
305
+ function formatVoiceReplyExecutionFailure(
306
+ label: string,
307
+ result: TelegramVoiceExecResult,
308
+ ): string {
309
+ const parts = [
310
+ `${label} exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
311
+ ];
312
+ if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
313
+ if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
314
+ return parts.join("\n\n");
315
+ }
316
+
317
+ async function runVoiceReplyCommand(
318
+ label: string,
319
+ config: TelegramOutboundCommandTemplateConfig,
320
+ values: Record<string, string>,
321
+ options: {
322
+ cwd: string;
323
+ timeout: number;
324
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
325
+ stdin?: string;
326
+ },
327
+ ): Promise<TelegramVoiceExecResult> {
328
+ if (!options.execCommand) {
329
+ throw new Error("execCommand is required for command template execution");
330
+ }
331
+ const invocation = buildCommandTemplateInvocation(
332
+ config,
333
+ values,
334
+ options.cwd,
335
+ {
336
+ emptyMessage: "Outbound voice template is empty",
337
+ missingLabel: "outbound voice template",
338
+ },
339
+ );
340
+ const result = await options.execCommand(
341
+ invocation.command,
342
+ invocation.args,
343
+ {
344
+ cwd: options.cwd,
345
+ timeout: options.timeout,
346
+ ...(typeof config === "object" && config.retry !== undefined
347
+ ? {
348
+ retry: resolveOutboundNumericControlField(
349
+ config.retry,
350
+ {},
351
+ "retry",
352
+ ),
353
+ }
354
+ : {}),
355
+ ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
356
+ },
357
+ );
358
+ if (result.code !== 0)
359
+ throw new Error(formatVoiceReplyExecutionFailure(label, result));
360
+ return result;
361
+ }
362
+
363
+ function normalizeOutboundHandlerStringList(
364
+ value: string | string[] | undefined,
365
+ ): string[] {
366
+ if (Array.isArray(value))
367
+ return value
368
+ .map(String)
369
+ .map((item) => item.trim())
370
+ .filter(Boolean);
371
+ if (typeof value === "string" && value.trim()) return [value.trim()];
372
+ return [];
373
+ }
374
+
375
+ function outboundHandlerMatchesType(
376
+ handler: TelegramOutboundHandlerConfig,
377
+ type: string,
378
+ ): boolean {
379
+ const selectors = [
380
+ ...normalizeOutboundHandlerStringList(handler.type),
381
+ ...normalizeOutboundHandlerStringList(handler.match),
382
+ ];
383
+ if (selectors.length === 0) return false;
384
+ return selectors.includes(type);
385
+ }
386
+
387
+ export function findTelegramOutboundHandlers(
388
+ handlers: TelegramOutboundHandlerConfig[] | undefined,
389
+ type: string,
390
+ ): TelegramOutboundHandlerConfig[] {
391
+ if (!Array.isArray(handlers)) return [];
392
+ return handlers.filter(
393
+ (handler) =>
394
+ !!handler &&
395
+ typeof handler === "object" &&
396
+ outboundHandlerMatchesType(handler, type),
397
+ );
398
+ }
399
+
400
+ function getTelegramVoiceHandlerCompositionSteps(
401
+ handler: TelegramOutboundHandlerConfig,
402
+ ): TelegramOutboundCommandTemplateConfig[] {
403
+ if (Array.isArray(handler.template)) {
404
+ return expandCommandTemplateConfigs(
405
+ handler,
406
+ ) as TelegramOutboundCommandTemplateConfig[];
407
+ }
408
+ return [];
409
+ }
410
+
411
+ function extractVoiceReplyPath(stdout: string): string {
412
+ const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
413
+ if (!path) throw new Error("Voice generator did not print an output path");
414
+ return path;
415
+ }
416
+
417
+ function getVoiceReplyOutputPath(
418
+ config: TelegramOutboundHandlerConfig,
419
+ values: Record<string, string>,
420
+ stdout: string,
421
+ ): string {
422
+ const output = config.output ?? "stdout";
423
+ if (output === "stdout") return extractVoiceReplyPath(stdout);
424
+ const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
425
+ if (keyMatch && Object.hasOwn(values, keyMatch[1])) {
426
+ return values[keyMatch[1]] ?? "";
427
+ }
428
+ return output.replace(
429
+ /\{([A-Za-z_][A-Za-z0-9_-]*)\}/g,
430
+ (_match, key: string) => values[key] ?? "",
431
+ );
432
+ }
433
+
434
+ function getVoiceReplyTemplateValues(
435
+ text: string,
436
+ options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
437
+ ): Record<string, string> {
438
+ return {
439
+ text,
440
+ type: "voice",
441
+ mp3: options.mp3Path,
442
+ ogg: options.oggPath,
443
+ ...(options.lang ? { lang: options.lang } : {}),
444
+ ...(options.rate ? { rate: options.rate } : {}),
445
+ };
446
+ }
447
+
448
+ function getDefaultTelegramVoiceTempDir(): string {
449
+ const agentDir = process.env.PI_CODING_AGENT_DIR
450
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
451
+ : join(homedir(), ".pi", "agent");
452
+ return join(agentDir, "tmp", "telegram");
453
+ }
454
+
455
+ async function generateTelegramVoiceReplyFileWithHandler(
456
+ text: string,
457
+ options: {
458
+ lang?: string;
459
+ rate?: string;
460
+ handler: TelegramOutboundHandlerConfig;
461
+ tempDir: string;
462
+ cwd: string;
463
+ timeout: number;
464
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
465
+ },
466
+ ): Promise<string> {
467
+ await mkdir(options.tempDir, { recursive: true });
468
+ const artifactId = randomUUID();
469
+ const values = getVoiceReplyTemplateValues(text, {
470
+ lang: options.lang,
471
+ rate: options.rate,
472
+ mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
473
+ oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
474
+ });
475
+ const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
476
+ if (steps.length > 0) {
477
+ const startedAt = Date.now();
478
+ let stdout = text;
479
+ for (const [index, step] of steps.entries()) {
480
+ try {
481
+ const result = await runVoiceReplyCommand(
482
+ `Outbound voice template step ${index + 1}`,
483
+ step,
484
+ values,
485
+ {
486
+ cwd: options.cwd,
487
+ timeout: getVoiceReplyCompositionStepTimeout(
488
+ options.timeout,
489
+ step,
490
+ startedAt,
491
+ ),
492
+ execCommand: options.execCommand,
493
+ stdin: stdout,
494
+ },
495
+ );
496
+ stdout = result.stdout;
497
+ } catch (error) {
498
+ if (typeof step === "object" && step.failure === "root") throw error;
499
+ stdout = "";
500
+ }
501
+ }
502
+ return getVoiceReplyOutputPath(options.handler, values, stdout);
503
+ }
504
+ const result = await runVoiceReplyCommand(
505
+ "Outbound voice template",
506
+ options.handler,
507
+ values,
508
+ {
509
+ cwd: options.cwd,
510
+ timeout: options.timeout,
511
+ execCommand: options.execCommand,
512
+ stdin: text,
513
+ },
514
+ );
515
+ return getVoiceReplyOutputPath(options.handler, values, result.stdout);
516
+ }
517
+
518
+ export async function generateTelegramVoiceReplyFile(
519
+ text: string,
520
+ options: {
521
+ lang?: string;
522
+ rate?: string;
523
+ handler?: TelegramOutboundHandlerConfig;
524
+ tempDir?: string;
525
+ cwd?: string;
526
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
527
+ },
528
+ ): Promise<string | undefined> {
529
+ const handler = options.handler;
530
+ if (!handler?.template) return undefined;
531
+ return generateTelegramVoiceReplyFileWithHandler(text, {
532
+ lang: options.lang,
533
+ rate: options.rate,
534
+ handler,
535
+ tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
536
+ cwd: options.cwd ?? process.cwd(),
537
+ timeout: getVoiceReplyTimeout(handler),
538
+ execCommand: options.execCommand,
539
+ });
540
+ }
541
+
542
+ function getOutboundTextTemplateValues(text: string): Record<string, string> {
543
+ return { text, type: "text" };
544
+ }
545
+
546
+ async function transformTelegramOutboundTextWithHandler(
547
+ text: string,
548
+ options: {
549
+ handler: TelegramOutboundHandlerConfig;
550
+ cwd: string;
551
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
552
+ },
553
+ ): Promise<string> {
554
+ const values = getOutboundTextTemplateValues(text);
555
+ const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
556
+ if (steps.length > 0) {
557
+ const startedAt = Date.now();
558
+ let stdout = text;
559
+ for (const [index, step] of steps.entries()) {
560
+ try {
561
+ const result = await runVoiceReplyCommand(
562
+ `Outbound text template step ${index + 1}`,
563
+ step,
564
+ values,
565
+ {
566
+ cwd: options.cwd,
567
+ timeout: getVoiceReplyCompositionStepTimeout(
568
+ getVoiceReplyTimeout(options.handler),
569
+ step,
570
+ startedAt,
571
+ ),
572
+ execCommand: options.execCommand,
573
+ stdin: stdout,
574
+ },
575
+ );
576
+ stdout = result.stdout;
577
+ } catch (error) {
578
+ if (typeof step === "object" && step.failure === "root") throw error;
579
+ stdout = "";
580
+ }
581
+ if (!stdout) stdout = text;
582
+ }
583
+ return stdout.trim() || text;
584
+ }
585
+ const result = await runVoiceReplyCommand(
586
+ "Outbound text template",
587
+ options.handler,
588
+ values,
589
+ {
590
+ cwd: options.cwd,
591
+ timeout: getVoiceReplyTimeout(options.handler),
592
+ execCommand: options.execCommand,
593
+ stdin: text,
594
+ },
595
+ );
596
+ return result.stdout.trim() || text;
597
+ }
598
+
599
+ export async function transformTelegramOutboundText(
600
+ text: string,
601
+ options: {
602
+ handlers?: TelegramOutboundHandlerConfig[];
603
+ cwd?: string;
604
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
605
+ recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
606
+ },
607
+ ): Promise<string> {
608
+ let transformed = text;
609
+ for (const handler of findTelegramOutboundHandlers(
610
+ options.handlers,
611
+ "text",
612
+ )) {
613
+ try {
614
+ transformed = await transformTelegramOutboundTextWithHandler(
615
+ transformed,
616
+ {
617
+ handler,
618
+ cwd: options.cwd ?? process.cwd(),
619
+ execCommand: options.execCommand,
620
+ },
621
+ );
622
+ } catch (error) {
623
+ options.recordRuntimeEvent?.("outbound-text-handler", error, {
624
+ handler: outboundHandlerMatchesType(handler, "text")
625
+ ? "text"
626
+ : "unknown",
627
+ });
628
+ }
629
+ }
630
+ return transformed;
631
+ }
632
+
633
+ function isTelegramInlineKeyboardLike(
634
+ replyMarkup: unknown,
635
+ ): replyMarkup is TelegramInlineKeyboardLike {
636
+ if (!replyMarkup || typeof replyMarkup !== "object") return false;
637
+ const keyboard = (replyMarkup as { inline_keyboard?: unknown })
638
+ .inline_keyboard;
639
+ return Array.isArray(keyboard);
640
+ }
641
+
642
+ async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
643
+ replyMarkup: TReplyMarkup | undefined,
644
+ options: Omit<TelegramOutboundTextTransformOptions, "replyMarkup">,
645
+ ): Promise<TReplyMarkup | undefined> {
646
+ if (!isTelegramInlineKeyboardLike(replyMarkup)) return replyMarkup;
647
+ const translatedRows = [];
648
+ for (const row of replyMarkup.inline_keyboard) {
649
+ const translatedRow = [];
650
+ for (const button of row) {
651
+ const text = await transformTelegramOutboundText(button.text, options);
652
+ translatedRow.push({ ...button, text });
653
+ }
654
+ translatedRows.push(translatedRow);
655
+ }
656
+ return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
657
+ }
658
+
659
+ export async function transformTelegramOutboundTextReply<
660
+ TReplyMarkup = unknown,
661
+ >(
662
+ text: string,
663
+ options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
664
+ ): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
665
+ const transformOptions = {
666
+ handlers: options.handlers,
667
+ cwd: options.cwd,
668
+ execCommand: options.execCommand,
669
+ recordRuntimeEvent: options.recordRuntimeEvent,
670
+ };
671
+ const transformedText = await transformTelegramOutboundText(
672
+ text,
673
+ transformOptions,
674
+ );
675
+ const replyMarkup = await transformTelegramOutboundReplyMarkup(
676
+ options.replyMarkup,
677
+ transformOptions,
678
+ );
679
+ return { text: transformedText, ...(replyMarkup ? { replyMarkup } : {}) };
680
+ }
681
+
682
+ export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
683
+ deps: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
684
+ ): Pick<
685
+ TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
686
+ "sendTextReply" | "sendMarkdownReply"
687
+ > {
688
+ return {
689
+ sendTextReply: async (chatId, replyToMessageId, text, options) => {
690
+ const transformed = await transformTelegramOutboundText(text, {
691
+ handlers: deps.getHandlers?.(),
692
+ cwd: deps.cwd,
693
+ execCommand: deps.execCommand,
694
+ recordRuntimeEvent: deps.recordRuntimeEvent,
695
+ });
696
+ return deps.sendTextReply(chatId, replyToMessageId, transformed, options);
697
+ },
698
+ sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
699
+ const transformed = await transformTelegramOutboundTextReply(markdown, {
700
+ handlers: deps.getHandlers?.(),
701
+ cwd: deps.cwd,
702
+ execCommand: deps.execCommand,
703
+ recordRuntimeEvent: deps.recordRuntimeEvent,
704
+ replyMarkup: options?.replyMarkup,
705
+ });
706
+ return deps.sendMarkdownReply(
707
+ chatId,
708
+ replyToMessageId,
709
+ transformed.text,
710
+ {
711
+ ...options,
712
+ ...(transformed.replyMarkup
713
+ ? { replyMarkup: transformed.replyMarkup }
714
+ : {}),
715
+ },
716
+ );
717
+ },
718
+ };
719
+ }
720
+
721
+ export function createTelegramOutboundTextPreviewRuntime<
722
+ TReplyMarkup = unknown,
723
+ >(
724
+ deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
725
+ ): Pick<
726
+ TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
727
+ "finalizeMarkdownPreview"
728
+ > {
729
+ return {
730
+ finalizeMarkdownPreview: async (
731
+ chatId,
732
+ markdown,
733
+ replyToMessageId,
734
+ options,
735
+ ) => {
736
+ const transformed = await transformTelegramOutboundTextReply(markdown, {
737
+ handlers: deps.getHandlers?.(),
738
+ cwd: deps.cwd,
739
+ execCommand: deps.execCommand,
740
+ recordRuntimeEvent: deps.recordRuntimeEvent,
741
+ replyMarkup: options?.replyMarkup,
742
+ });
743
+ return deps.finalizeMarkdownPreview(
744
+ chatId,
745
+ transformed.text,
746
+ replyToMessageId,
747
+ {
748
+ ...options,
749
+ ...(transformed.replyMarkup
750
+ ? { replyMarkup: transformed.replyMarkup }
751
+ : {}),
752
+ },
753
+ );
754
+ },
755
+ };
756
+ }
757
+
758
+ export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
759
+ markdown: string;
760
+ replyMarkup?: TReplyMarkup;
761
+ voiceText?: string;
762
+ voiceReplies?: TelegramVoiceReplyItem[];
763
+ lang?: string;
764
+ rate?: string;
765
+ }
766
+
767
+ // --- Voice Policy Re-Exports ---
768
+ export {
769
+ clearTelegramVoiceSynthesisProviders,
770
+ clearTelegramVoiceTranscriptionProviders,
771
+ computeVoicePromptContribution,
772
+ computeVoiceTurnFlags,
773
+ getTelegramVoiceReplyMode,
774
+ getTelegramVoiceSynthesisProviders,
775
+ getTelegramVoiceTranscriptionProviders,
776
+ hasTelegramVoiceSynthesisProvider,
777
+ hasTelegramVoiceTranscriptionProvider,
778
+ isVoiceTurn,
779
+ registerTelegramVoiceSynthesisProvider,
780
+ registerTelegramVoiceTranscriptionProvider,
781
+ shouldSuppressPreviewForVoice,
782
+ type TelegramVoiceReplyMode,
783
+ type TelegramVoiceSynthesisProvider,
784
+ type TelegramVoiceSynthesisProviderResult,
785
+ type TelegramVoiceTranscriptionFile,
786
+ type TelegramVoiceTranscriptionProvider,
787
+ type TelegramVoiceTranscriptionProviderResult,
788
+ type TelegramVoiceTurnView,
789
+ } from "./voice.ts";
790
+
791
+ export function createTelegramVoiceReplySender(
792
+ deps: TelegramVoiceReplySenderDeps,
793
+ ) {
794
+ return createTelegramVoiceReplySenderWithPorts(deps, {
795
+ findVoiceHandlers: (handlers) =>
796
+ findTelegramOutboundHandlers(
797
+ handlers as TelegramOutboundHandlerConfig[] | undefined,
798
+ "voice",
799
+ ),
800
+ generateVoiceFile: (text, options) =>
801
+ generateTelegramVoiceReplyFile(text, {
802
+ lang: options.lang,
803
+ rate: options.rate,
804
+ handler: options.handler,
805
+ tempDir: options.tempDir,
806
+ cwd: options.cwd,
807
+ execCommand: options.execCommand,
808
+ }),
809
+ getProgrammaticVoiceHandlers: () =>
810
+ getTelegramOutboundProgrammaticHandlers("voice"),
811
+ });
812
+ }
813
+
814
+ export {
815
+ createTelegramButtonActionStore,
816
+ createTelegramButtonPromptTurn,
817
+ createTelegramButtonReplyPlanner,
818
+ handleTelegramButtonCallbackQuery,
819
+ planTelegramButtonReply,
820
+ type TelegramButtonActionStore,
821
+ type TelegramButtonCallbackHandlerDeps,
822
+ type TelegramButtonCallbackQuery,
823
+ type TelegramButtonReplyPlan,
824
+ type TelegramOutboundButtonAction,
825
+ type TelegramOutboundButtonMarkup,
826
+ type TelegramOutboundButtonStoredAction,
827
+ } from "./outbound-buttons.ts";
828
+
829
+ export function createTelegramOutboundReplyPlanner(
830
+ store: Pick<TelegramButtonActionStore, "register">,
831
+ ): (
832
+ markdown: string,
833
+ ) => TelegramOutboundReplyPlan<TelegramOutboundButtonMarkup> {
834
+ return (markdown) => {
835
+ const buttonReply = planTelegramButtonReply(markdown, {
836
+ registerAction: store.register,
837
+ });
838
+
839
+ // Button replies can also contain <!-- telegram_voice --> markup
840
+ const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
841
+
842
+ return {
843
+ markdown: voiceReply.markdown,
844
+ ...(buttonReply.replyMarkup
845
+ ? { replyMarkup: buttonReply.replyMarkup }
846
+ : {}),
847
+ ...(voiceReply.voiceText ? { voiceText: voiceReply.voiceText } : {}),
848
+ ...(voiceReply.voiceReplies
849
+ ? { voiceReplies: voiceReply.voiceReplies }
850
+ : {}),
851
+ ...(voiceReply.lang ? { lang: voiceReply.lang } : {}),
852
+ ...(voiceReply.rate ? { rate: voiceReply.rate } : {}),
853
+ };
854
+ };
855
+ }
856
+
857
+ /**
858
+ * Create an artifact sender that delivers planned voice replies for a turn.
859
+ * Iterates over `voiceReplies` (or a single `voiceText`) and sends each as
860
+ * a Telegram voice message via the voice reply sender. Throws if no voice
861
+ * reply could be delivered.
862
+ */
863
+
864
+ // --- Outbound Reply Artifacts ---
865
+
866
+ export function createTelegramOutboundReplyArtifactSender(
867
+ deps: TelegramVoiceReplySenderDeps,
868
+ ) {
869
+ const sendVoiceReply = createTelegramVoiceReplySender(deps);
870
+ return async function sendOutboundReplyArtifacts(
871
+ turn: TelegramVoiceReplyTurnView,
872
+ plan: Pick<
873
+ TelegramOutboundReplyPlan,
874
+ "voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
875
+ >,
876
+ options?: { replyToPrompt?: boolean },
877
+ ): Promise<void> {
878
+ // Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
879
+ const voiceReplies = plan.voiceReplies?.length
880
+ ? plan.voiceReplies
881
+ : plan.voiceText
882
+ ? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
883
+ : [];
884
+
885
+ let anyDelivered = false;
886
+
887
+ for (const reply of voiceReplies) {
888
+ try {
889
+ await sendVoiceReply(turn, reply.text, {
890
+ lang: reply.lang ?? plan.lang,
891
+ rate: reply.rate ?? plan.rate,
892
+ // Only attach reply parameters to the first voice message
893
+ replyToPrompt: options?.replyToPrompt === true && !anyDelivered,
894
+ replyMarkup: !anyDelivered ? plan.replyMarkup : undefined,
895
+ });
896
+ anyDelivered = true;
897
+ } catch {
898
+ // sendVoiceReply already recorded the error; continue to next reply
899
+ }
900
+ }
901
+
902
+ if (!anyDelivered) {
903
+ throw new Error(
904
+ "Failed to send voice reply: every voice synthesis provider failed.",
905
+ );
906
+ }
907
+ };
908
+ }