@llblab/pi-telegram 0.10.8 → 0.11.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.
@@ -5,25 +5,81 @@
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import { mkdir } from "node:fs/promises";
8
+ import { mkdir, unlink } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
- import { basename, join, resolve } from "node:path";
10
+ import { basename, extname, join, resolve } from "node:path";
11
11
 
12
12
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
13
  import type { PendingTelegramTurn } from "./queue.ts";
14
- import { buildTelegramMultipartReplyParameters } from "./replies.ts";
15
- import { truncateTelegramQueueSummary } from "./turns.ts";
14
+
15
+ import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
16
+
17
+ const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
18
+ const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
19
+
20
+ function buildVoiceReplyParameters(
21
+ replyToPrompt: boolean | undefined,
22
+ replyToMessageId: number | undefined,
23
+ ): string | undefined {
24
+ if (replyToPrompt === false || replyToMessageId === undefined)
25
+ return undefined;
26
+ return JSON.stringify({
27
+ message_id: replyToMessageId,
28
+ allow_sending_without_reply: true,
29
+ });
30
+ }
31
+
32
+ async function ensureTelegramVoiceFileFormat(
33
+ filePath: string,
34
+ ): Promise<string> {
35
+ const ext = extname(filePath).toLowerCase();
36
+ if (ext === ".opus" || ext === ".ogg") {
37
+ return filePath;
38
+ }
39
+ throw new Error(
40
+ `Voice synthesis provider must return .ogg or .opus files, got ${ext}. ` +
41
+ `Providers should handle format conversion internally.`,
42
+ );
43
+ }
44
+
16
45
  import {
17
46
  buildCommandTemplateInvocation,
18
47
  expandCommandTemplateConfigs,
19
- substituteCommandTemplateToken,
20
48
  type CommandTemplateObjectConfig,
21
49
  } from "./command-templates.ts";
50
+ import { truncateTelegramQueueSummary } from "./queue.ts";
22
51
 
23
52
  const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
24
53
  const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
25
54
  const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
26
55
 
56
+ // --- Types ---
57
+
58
+ /**
59
+ * Record a runtime event that appears in `/telegram-status`.
60
+ * Voice synthesis provider extensions (e.g. `pi-xai-voice`) can call this to surface
61
+ * diagnostics alongside pi-telegram's own events. Events are silently dropped
62
+ * when pi-telegram is not loaded.
63
+ */
64
+ export function recordTelegramRuntimeEvent(
65
+ category: string,
66
+ error: unknown,
67
+ details?: Record<string, unknown>,
68
+ ): void {
69
+ const recorder = (globalThis as Record<string, unknown>)[
70
+ VOICE_EVENT_RECORDER_KEY
71
+ ];
72
+ if (typeof recorder === "function") {
73
+ (
74
+ recorder as (
75
+ category: string,
76
+ error: unknown,
77
+ details?: Record<string, unknown>,
78
+ ) => void
79
+ )(category, error, details);
80
+ }
81
+ }
82
+
27
83
  export type TelegramOutboundCommandTemplateConfig =
28
84
  | string
29
85
  | CommandTemplateObjectConfig;
@@ -84,12 +140,15 @@ export interface TelegramVoiceReplySenderDeps {
84
140
  ) => Promise<unknown>;
85
141
  sendTextReply?: (
86
142
  chatId: number,
87
- replyToMessageId: number,
143
+ replyToMessageId: number | undefined,
88
144
  text: string,
145
+ options?: { parseMode?: "HTML" },
89
146
  ) => Promise<unknown>;
147
+ sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
148
+ sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
90
149
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
91
- tempDir?: string;
92
150
  cwd?: string;
151
+ tempDir?: string;
93
152
  recordRuntimeEvent?: (
94
153
  category: string,
95
154
  error: unknown,
@@ -97,6 +156,71 @@ export interface TelegramVoiceReplySenderDeps {
97
156
  ) => void;
98
157
  }
99
158
 
159
+ // --- Programmatic Outbound Handler Registry ---
160
+
161
+ export type TelegramOutboundProgrammaticHandler = (
162
+ text: string,
163
+ options?: { lang?: string; rate?: string },
164
+ ) => Promise<string>;
165
+
166
+ export interface TelegramOutboundHandlerRegistry {
167
+ handlers: Map<string, TelegramOutboundProgrammaticHandler[]>;
168
+ }
169
+
170
+ // --- Programmatic Outbound Handler Registry Runtime ---
171
+
172
+ function getOrCreateOutboundHandlerRegistry(): TelegramOutboundHandlerRegistry {
173
+ const existing = (globalThis as Record<string, unknown>)[
174
+ OUTBOUND_HANDLER_REGISTRY_KEY
175
+ ];
176
+ if (
177
+ existing &&
178
+ typeof existing === "object" &&
179
+ existing !== null &&
180
+ "handlers" in existing &&
181
+ existing.handlers instanceof Map
182
+ ) {
183
+ return existing as TelegramOutboundHandlerRegistry;
184
+ }
185
+ const registry: TelegramOutboundHandlerRegistry = {
186
+ handlers: new Map(),
187
+ };
188
+ (globalThis as Record<string, unknown>)[OUTBOUND_HANDLER_REGISTRY_KEY] =
189
+ registry;
190
+ return registry;
191
+ }
192
+
193
+ export function registerTelegramOutboundHandler(
194
+ kind: string,
195
+ handler: TelegramOutboundProgrammaticHandler,
196
+ ): () => void {
197
+ const registry = getOrCreateOutboundHandlerRegistry();
198
+ const list = registry.handlers.get(kind) ?? [];
199
+ list.push(handler);
200
+ registry.handlers.set(kind, list);
201
+ return () => {
202
+ const updated = registry.handlers.get(kind) ?? [];
203
+ const index = updated.indexOf(handler);
204
+ if (index !== -1) {
205
+ updated.splice(index, 1);
206
+ registry.handlers.set(kind, updated);
207
+ }
208
+ };
209
+ }
210
+
211
+ export function hasTelegramOutboundHandler(kind: string): boolean {
212
+ const registry = getOrCreateOutboundHandlerRegistry();
213
+ const list = registry.handlers.get(kind);
214
+ return !!list && list.length > 0;
215
+ }
216
+
217
+ export function getTelegramOutboundProgrammaticHandlers(
218
+ kind: string,
219
+ ): TelegramOutboundProgrammaticHandler[] {
220
+ const registry = getOrCreateOutboundHandlerRegistry();
221
+ return [...(registry.handlers.get(kind) ?? [])];
222
+ }
223
+
100
224
  export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
101
225
  execCommand: TelegramVoiceReplySenderDeps["execCommand"];
102
226
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
@@ -133,7 +257,9 @@ export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
133
257
  replyMarkup?: TReplyMarkup;
134
258
  }
135
259
 
136
- export interface TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup = unknown> {
260
+ export interface TelegramOutboundTextPreviewRuntimeDeps<
261
+ TReplyMarkup = unknown,
262
+ > {
137
263
  execCommand: TelegramVoiceReplySenderDeps["execCommand"];
138
264
  getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
139
265
  finalizeMarkdownPreview: (
@@ -277,175 +403,33 @@ function collectTopLevelHtmlComments(markdown: string): {
277
403
  return { comments };
278
404
  }
279
405
 
280
- function replaceTopLevelHtmlComments(
281
- markdown: string,
282
- replacer: (comment: TelegramTopLevelHtmlComment) => string,
283
- ): string {
284
- const { comments } = collectTopLevelHtmlComments(markdown);
285
- if (comments.length === 0) return markdown;
286
- let result = "";
287
- let offset = 0;
288
- for (const comment of comments) {
289
- result += markdown.slice(offset, comment.start);
290
- result += replacer(comment);
291
- offset = comment.end;
292
- }
293
- return result + markdown.slice(offset);
294
- }
295
-
296
- function findTopLevelOpenOrPartialHtmlCommentIndex(markdown: string): number {
297
- const { openCommentStart } = collectTopLevelHtmlComments(markdown);
298
- if (openCommentStart !== undefined) return openCommentStart;
299
- let offset = 0;
300
- let fence: TelegramTopLevelFenceState | undefined;
301
- while (offset < markdown.length) {
302
- const lineEnd = getMarkdownLineEnd(markdown, offset);
303
- const line = getMarkdownLineText(markdown, offset, lineEnd);
304
- const isLastLine = lineEnd >= markdown.length;
305
- if (fence) {
306
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
307
- offset = lineEnd;
308
- continue;
309
- }
310
- const nextFence = getTopLevelOpeningFence(line);
311
- if (nextFence) {
312
- fence = nextFence;
313
- offset = lineEnd;
314
- continue;
315
- }
316
- if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
317
- return offset;
318
- }
319
- offset = lineEnd;
320
- }
321
- return -1;
322
- }
323
-
324
- function parseTopLevelTelegramComment(
325
- comment: TelegramTopLevelHtmlComment,
326
- command: string,
327
- ): { head: string; body?: string } | undefined {
328
- const normalizedContent = comment.content.replace(/^\s+/, "");
329
- const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
330
- const head = rawHead.trimStart();
331
- if (!head.startsWith(command)) return undefined;
332
- const nextChar = head[command.length];
333
- if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
334
- return {
335
- head: head.slice(command.length),
336
- ...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
337
- };
338
- }
339
-
340
- function parseTelegramCommentAttributes(input: string): Record<string, string> {
341
- const attributes: Record<string, string> = {};
342
- for (const match of input.matchAll(
343
- /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
344
- )) {
345
- const key = match[1];
346
- const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
347
- if (value) attributes[key] = value;
348
- }
349
- return attributes;
350
- }
406
+ // --- Voice Delivery Helpers ---
351
407
 
352
- function parseVoiceReplyAttributes(input: string): {
353
- lang?: string;
354
- rate?: string;
355
- text?: string;
408
+ function extractVoiceResult(result: any): {
409
+ filePath: string;
410
+ transcriptText?: string;
356
411
  } {
357
- const attributes = parseTelegramCommentAttributes(input);
412
+ if (typeof result === "string") {
413
+ return { filePath: result };
414
+ }
358
415
  return {
359
- ...(attributes.lang ? { lang: attributes.lang } : {}),
360
- ...(attributes.rate ? { rate: attributes.rate } : {}),
361
- ...(attributes.text ? { text: attributes.text } : {}),
416
+ filePath: result.audioPath,
417
+ transcriptText: result.transcriptText,
362
418
  };
363
419
  }
364
420
 
365
- function parseVoiceCommentBody(
366
- head: string,
367
- body: string | undefined,
368
- ): {
369
- attrs: string;
370
- text: string;
371
- } {
372
- const trimmedHead = head.trim();
373
- if (body !== undefined) {
374
- return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
375
- }
376
- if (trimmedHead.startsWith(":")) {
377
- return { attrs: "", text: trimmedHead.slice(1).trim() };
421
+ async function sendVoiceChatAction(
422
+ deps: TelegramVoiceReplySenderDeps,
423
+ chatId: number,
424
+ ) {
425
+ if (deps.sendRecordVoiceAction) {
426
+ await deps.sendRecordVoiceAction(chatId).catch(() => {});
427
+ } else {
428
+ await deps.sendChatAction?.(chatId, "record_voice").catch(() => {});
378
429
  }
379
- const attrs = parseVoiceReplyAttributes(trimmedHead);
380
- return { attrs: trimmedHead, text: attrs.text ?? "" };
381
- }
382
-
383
- function normalizeMarkdownAfterVoiceExtraction(markdown: string): string {
384
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
385
- }
386
-
387
- export function stripTelegramCommentMarkupForPreview(markdown: string): string {
388
- const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
389
- const openBlockIndex =
390
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
391
- const previewMarkdown =
392
- openBlockIndex >= 0
393
- ? withoutClosedBlocks.slice(0, openBlockIndex)
394
- : withoutClosedBlocks;
395
- return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
396
- }
397
-
398
- export function stripTelegramCommentMarkupForDelivery(
399
- markdown: string,
400
- ): string {
401
- const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
402
- const openBlockIndex =
403
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
404
- const deliveryMarkdown =
405
- openBlockIndex >= 0
406
- ? withoutClosedBlocks.slice(0, openBlockIndex)
407
- : withoutClosedBlocks;
408
- return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
409
- }
410
-
411
- export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
412
- return stripTelegramCommentMarkupForPreview(markdown);
413
430
  }
414
431
 
415
- export function planTelegramVoiceReply(
416
- markdown: string,
417
- ): TelegramVoiceReplyPlan {
418
- const voiceReplies: TelegramVoiceReplyItem[] = [];
419
- let lang: string | undefined;
420
- let rate: string | undefined;
421
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
422
- const command = parseTopLevelTelegramComment(comment, "telegram_voice");
423
- if (!command) return "";
424
- const parsed = parseVoiceCommentBody(command.head, command.body);
425
- const attrs = parseVoiceReplyAttributes(parsed.attrs);
426
- if (parsed.text) {
427
- voiceReplies.push({
428
- text: parsed.text,
429
- ...(attrs.lang ? { lang: attrs.lang } : {}),
430
- ...(attrs.rate ? { rate: attrs.rate } : {}),
431
- });
432
- }
433
- if (attrs.lang) lang = attrs.lang;
434
- if (attrs.rate) rate = attrs.rate;
435
- return "";
436
- });
437
- const voiceText = voiceReplies
438
- .map((reply) => reply.text)
439
- .join("\n\n")
440
- .trim();
441
- return {
442
- markdown: stripTelegramCommentMarkupForDelivery(stripped),
443
- ...(voiceText ? { voiceText } : {}),
444
- ...(voiceReplies.length > 0 ? { voiceReplies } : {}),
445
- ...(lang ? { lang } : {}),
446
- ...(rate ? { rate } : {}),
447
- };
448
- }
432
+ // --- Voice Reply Timeout Helpers ---
449
433
 
450
434
  function getVoiceReplyConfiguredTimeout(
451
435
  config: TelegramOutboundCommandTemplateConfig | undefined,
@@ -493,12 +477,6 @@ function formatVoiceReplyExecutionFailure(
493
477
  return parts.join("\n\n");
494
478
  }
495
479
 
496
- function extractVoiceReplyPath(stdout: string): string {
497
- const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
498
- if (!path) throw new Error("Voice generator did not print an output path");
499
- return path;
500
- }
501
-
502
480
  async function runVoiceReplyCommand(
503
481
  label: string,
504
482
  config: TelegramOutboundCommandTemplateConfig,
@@ -510,6 +488,9 @@ async function runVoiceReplyCommand(
510
488
  stdin?: string;
511
489
  },
512
490
  ): Promise<TelegramVoiceExecResult> {
491
+ if (!options.execCommand) {
492
+ throw new Error("execCommand is required for command template execution");
493
+ }
513
494
  const invocation = buildCommandTemplateInvocation(
514
495
  config,
515
496
  values,
@@ -536,43 +517,6 @@ async function runVoiceReplyCommand(
536
517
  return result;
537
518
  }
538
519
 
539
- function getVoiceReplyOutputPath(
540
- config: TelegramOutboundHandlerConfig,
541
- values: Record<string, string>,
542
- stdout: string,
543
- ): string {
544
- const output = config.output ?? "stdout";
545
- if (output === "stdout") return extractVoiceReplyPath(stdout);
546
- const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
547
- if (keyMatch && Object.hasOwn(values, keyMatch[1]))
548
- return values[keyMatch[1]] ?? "";
549
- return substituteCommandTemplateToken(
550
- output,
551
- values,
552
- "outbound voice template",
553
- );
554
- }
555
-
556
- function getVoiceReplyTemplateValues(
557
- text: string,
558
- options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
559
- ): Record<string, string> {
560
- return {
561
- text,
562
- mp3: options.mp3Path,
563
- ogg: options.oggPath,
564
- ...(options.lang ? { lang: options.lang } : {}),
565
- ...(options.rate ? { rate: options.rate } : {}),
566
- };
567
- }
568
-
569
- function getDefaultTelegramVoiceTempDir(): string {
570
- const agentDir = process.env.PI_CODING_AGENT_DIR
571
- ? resolve(process.env.PI_CODING_AGENT_DIR)
572
- : join(homedir(), ".pi", "agent");
573
- return join(agentDir, "tmp", "telegram");
574
- }
575
-
576
520
  function normalizeOutboundHandlerStringList(
577
521
  value: string | string[] | undefined,
578
522
  ): string[] {
@@ -627,26 +571,70 @@ function getTelegramVoiceHandlerCompositionSteps(
627
571
  return [];
628
572
  }
629
573
 
630
- async function generateTelegramVoiceReplyFileWithHandler(
631
- text: string,
632
- options: {
633
- lang?: string;
634
- rate?: string;
635
- handler: TelegramOutboundHandlerConfig;
636
- tempDir: string;
637
- cwd: string;
638
- timeout: number;
639
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
640
- },
641
- ): Promise<string> {
642
- await mkdir(options.tempDir, { recursive: true });
643
- const artifactId = randomUUID();
644
- const values = getVoiceReplyTemplateValues(text, {
645
- lang: options.lang,
646
- rate: options.rate,
647
- mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
648
- oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
649
- });
574
+ function extractVoiceReplyPath(stdout: string): string {
575
+ const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
576
+ if (!path) throw new Error("Voice generator did not print an output path");
577
+ return path;
578
+ }
579
+
580
+ function getVoiceReplyOutputPath(
581
+ config: TelegramOutboundHandlerConfig,
582
+ values: Record<string, string>,
583
+ stdout: string,
584
+ ): string {
585
+ const output = config.output ?? "stdout";
586
+ if (output === "stdout") return extractVoiceReplyPath(stdout);
587
+ const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
588
+ if (keyMatch && Object.hasOwn(values, keyMatch[1])) {
589
+ return values[keyMatch[1]] ?? "";
590
+ }
591
+ return output.replace(
592
+ /\{([A-Za-z_][A-Za-z0-9_-]*)\}/g,
593
+ (_match, key: string) => values[key] ?? "",
594
+ );
595
+ }
596
+
597
+ function getVoiceReplyTemplateValues(
598
+ text: string,
599
+ options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
600
+ ): Record<string, string> {
601
+ return {
602
+ text,
603
+ type: "voice",
604
+ mp3: options.mp3Path,
605
+ ogg: options.oggPath,
606
+ ...(options.lang ? { lang: options.lang } : {}),
607
+ ...(options.rate ? { rate: options.rate } : {}),
608
+ };
609
+ }
610
+
611
+ function getDefaultTelegramVoiceTempDir(): string {
612
+ const agentDir = process.env.PI_CODING_AGENT_DIR
613
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
614
+ : join(homedir(), ".pi", "agent");
615
+ return join(agentDir, "tmp", "telegram");
616
+ }
617
+
618
+ async function generateTelegramVoiceReplyFileWithHandler(
619
+ text: string,
620
+ options: {
621
+ lang?: string;
622
+ rate?: string;
623
+ handler: TelegramOutboundHandlerConfig;
624
+ tempDir: string;
625
+ cwd: string;
626
+ timeout: number;
627
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
628
+ },
629
+ ): Promise<string> {
630
+ await mkdir(options.tempDir, { recursive: true });
631
+ const artifactId = randomUUID();
632
+ const values = getVoiceReplyTemplateValues(text, {
633
+ lang: options.lang,
634
+ rate: options.rate,
635
+ mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
636
+ oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
637
+ });
650
638
  const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
651
639
  if (steps.length > 0) {
652
640
  const startedAt = Date.now();
@@ -684,9 +672,10 @@ async function generateTelegramVoiceReplyFileWithHandler(
684
672
  cwd: options.cwd,
685
673
  timeout: options.timeout,
686
674
  execCommand: options.execCommand,
675
+ stdin: text,
687
676
  },
688
677
  );
689
- return extractVoiceReplyPath(result.stdout);
678
+ return getVoiceReplyOutputPath(options.handler, values, result.stdout);
690
679
  }
691
680
 
692
681
  export async function generateTelegramVoiceReplyFile(
@@ -700,7 +689,6 @@ export async function generateTelegramVoiceReplyFile(
700
689
  execCommand: TelegramVoiceReplySenderDeps["execCommand"];
701
690
  },
702
691
  ): Promise<string | undefined> {
703
- const cwd = options.cwd ?? process.cwd();
704
692
  const handler = options.handler;
705
693
  if (!handler?.template && !handler?.pipe?.length) return undefined;
706
694
  return generateTelegramVoiceReplyFileWithHandler(text, {
@@ -708,7 +696,7 @@ export async function generateTelegramVoiceReplyFile(
708
696
  rate: options.rate,
709
697
  handler,
710
698
  tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
711
- cwd,
699
+ cwd: options.cwd ?? process.cwd(),
712
700
  timeout: getVoiceReplyTimeout(handler),
713
701
  execCommand: options.execCommand,
714
702
  });
@@ -781,7 +769,10 @@ export async function transformTelegramOutboundText(
781
769
  },
782
770
  ): Promise<string> {
783
771
  let transformed = text;
784
- for (const handler of findTelegramOutboundHandlers(options.handlers, "text")) {
772
+ for (const handler of findTelegramOutboundHandlers(
773
+ options.handlers,
774
+ "text",
775
+ )) {
785
776
  try {
786
777
  transformed = await transformTelegramOutboundTextWithHandler(
787
778
  transformed,
@@ -793,7 +784,9 @@ export async function transformTelegramOutboundText(
793
784
  );
794
785
  } catch (error) {
795
786
  options.recordRuntimeEvent?.("outbound-text-handler", error, {
796
- handler: outboundHandlerMatchesType(handler, "text") ? "text" : "unknown",
787
+ handler: outboundHandlerMatchesType(handler, "text")
788
+ ? "text"
789
+ : "unknown",
797
790
  });
798
791
  }
799
792
  }
@@ -804,7 +797,8 @@ function isTelegramInlineKeyboardLike(
804
797
  replyMarkup: unknown,
805
798
  ): replyMarkup is TelegramInlineKeyboardLike {
806
799
  if (!replyMarkup || typeof replyMarkup !== "object") return false;
807
- const keyboard = (replyMarkup as { inline_keyboard?: unknown }).inline_keyboard;
800
+ const keyboard = (replyMarkup as { inline_keyboard?: unknown })
801
+ .inline_keyboard;
808
802
  return Array.isArray(keyboard);
809
803
  }
810
804
 
@@ -825,7 +819,9 @@ async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
825
819
  return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
826
820
  }
827
821
 
828
- export async function transformTelegramOutboundTextReply<TReplyMarkup = unknown>(
822
+ export async function transformTelegramOutboundTextReply<
823
+ TReplyMarkup = unknown,
824
+ >(
829
825
  text: string,
830
826
  options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
831
827
  ): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
@@ -870,24 +866,36 @@ export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
870
866
  recordRuntimeEvent: deps.recordRuntimeEvent,
871
867
  replyMarkup: options?.replyMarkup,
872
868
  });
873
- return deps.sendMarkdownReply(chatId, replyToMessageId, transformed.text, {
874
- ...options,
875
- ...(transformed.replyMarkup
876
- ? { replyMarkup: transformed.replyMarkup }
877
- : {}),
878
- });
869
+ return deps.sendMarkdownReply(
870
+ chatId,
871
+ replyToMessageId,
872
+ transformed.text,
873
+ {
874
+ ...options,
875
+ ...(transformed.replyMarkup
876
+ ? { replyMarkup: transformed.replyMarkup }
877
+ : {}),
878
+ },
879
+ );
879
880
  },
880
881
  };
881
882
  }
882
883
 
883
- export function createTelegramOutboundTextPreviewRuntime<TReplyMarkup = unknown>(
884
+ export function createTelegramOutboundTextPreviewRuntime<
885
+ TReplyMarkup = unknown,
886
+ >(
884
887
  deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
885
888
  ): Pick<
886
889
  TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
887
890
  "finalizeMarkdownPreview"
888
891
  > {
889
892
  return {
890
- finalizeMarkdownPreview: async (chatId, markdown, replyToMessageId, options) => {
893
+ finalizeMarkdownPreview: async (
894
+ chatId,
895
+ markdown,
896
+ replyToMessageId,
897
+ options,
898
+ ) => {
891
899
  const transformed = await transformTelegramOutboundTextReply(markdown, {
892
900
  handlers: deps.getHandlers?.(),
893
901
  cwd: deps.cwd,
@@ -919,20 +927,91 @@ export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
919
927
  rate?: string;
920
928
  }
921
929
 
930
+ // --- Voice Policy Re-Exports ---
931
+ export {
932
+ clearTelegramVoiceSynthesisProviders,
933
+ clearTelegramVoiceTranscriptionProviders,
934
+ computeVoicePromptContribution,
935
+ computeVoiceTurnFlags,
936
+ getTelegramVoiceSynthesisProviders,
937
+ getTelegramVoiceReplyMode,
938
+ getTelegramVoiceTranscriptionProviders,
939
+ hasTelegramVoiceSynthesisProvider,
940
+ hasTelegramVoiceTranscriptionProvider,
941
+ isVoiceTurn,
942
+ registerTelegramVoiceSynthesisProvider,
943
+ registerTelegramVoiceTranscriptionProvider,
944
+ shouldSuppressPreviewForVoice,
945
+ type TelegramVoiceSynthesisProvider,
946
+ type TelegramVoiceSynthesisProviderResult,
947
+ type TelegramVoiceReplyMode,
948
+ type TelegramVoiceTranscriptionFile,
949
+ type TelegramVoiceTranscriptionProvider,
950
+ type TelegramVoiceTranscriptionProviderResult,
951
+ type TelegramVoiceTurnView,
952
+ } from "./voice.ts";
953
+
954
+ // --- Voice Delivery ---
955
+
956
+ /**
957
+ * Creates a function that sends voice replies using registered voice synthesis providers.
958
+ *
959
+ * This is the main entry point for delivering voice messages.
960
+ * The actual decision logic (when to use voice) lives in `lib/voice.ts`.
961
+ */
922
962
  export function createTelegramVoiceReplySender(
923
963
  deps: TelegramVoiceReplySenderDeps,
924
964
  ) {
965
+ async function uploadVoiceFile(
966
+ turn: TelegramVoiceReplyTurnView,
967
+ filePath: string,
968
+ options?: {
969
+ replyToPrompt?: boolean;
970
+ replyMarkup?: unknown;
971
+ transcriptText?: string;
972
+ },
973
+ ): Promise<void> {
974
+ const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
975
+ await sendVoiceChatAction(deps, turn.chatId);
976
+ const replyParameters = buildVoiceReplyParameters(
977
+ options?.replyToPrompt,
978
+ turn.replyToMessageId,
979
+ );
980
+ await deps.sendMultipart(
981
+ "sendVoice",
982
+ {
983
+ chat_id: String(turn.chatId),
984
+ ...(options?.transcriptText ? { caption: options.transcriptText } : {}),
985
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
986
+ ...(options?.replyMarkup !== undefined && options.replyMarkup !== null
987
+ ? {
988
+ reply_markup:
989
+ typeof options.replyMarkup === "string"
990
+ ? options.replyMarkup
991
+ : JSON.stringify(options.replyMarkup),
992
+ }
993
+ : {}),
994
+ },
995
+ "voice",
996
+ voiceFilePath,
997
+ basename(voiceFilePath),
998
+ );
999
+ }
1000
+
925
1001
  return async function sendVoiceReply(
926
1002
  turn: TelegramVoiceReplyTurnView,
927
1003
  text: string,
928
- options?: { lang?: string; rate?: string; replyToPrompt?: boolean },
1004
+ options?: {
1005
+ lang?: string;
1006
+ rate?: string;
1007
+ replyToPrompt?: boolean;
1008
+ replyMarkup?: unknown;
1009
+ },
929
1010
  ): Promise<void> {
930
- const handlers = findTelegramOutboundHandlers(
1011
+ for (const handler of findTelegramOutboundHandlers(
931
1012
  deps.getHandlers?.(),
932
1013
  "voice",
933
- );
934
- if (handlers.length === 0) return;
935
- for (const handler of handlers) {
1014
+ )) {
936
1015
  try {
937
1016
  const filePath = await generateTelegramVoiceReplyFile(text, {
938
1017
  lang: options?.lang,
@@ -943,29 +1022,89 @@ export function createTelegramVoiceReplySender(
943
1022
  execCommand: deps.execCommand,
944
1023
  });
945
1024
  if (!filePath) continue;
946
- const replyParameters = buildTelegramMultipartReplyParameters(
947
- options?.replyToPrompt === false ? undefined : turn.replyToMessageId,
948
- );
949
- await deps.sendMultipart(
950
- "sendVoice",
951
- {
952
- chat_id: String(turn.chatId),
953
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
954
- },
955
- "voice",
956
- filePath,
957
- basename(filePath),
958
- );
1025
+ await uploadVoiceFile(turn, filePath, {
1026
+ replyToPrompt: options?.replyToPrompt,
1027
+ replyMarkup: options?.replyMarkup,
1028
+ });
1029
+ return;
1030
+ } catch (error) {
1031
+ deps.recordRuntimeEvent?.("voice", error, { phase: "template-handler-send" });
1032
+ }
1033
+ }
1034
+
1035
+ for (const handler of getTelegramOutboundProgrammaticHandlers("voice")) {
1036
+ try {
1037
+ const filePath = await handler(text, {
1038
+ lang: options?.lang,
1039
+ rate: options?.rate,
1040
+ });
1041
+ if (!filePath) continue;
1042
+ await uploadVoiceFile(turn, filePath, {
1043
+ replyToPrompt: options?.replyToPrompt,
1044
+ replyMarkup: options?.replyMarkup,
1045
+ });
1046
+ return;
1047
+ } catch (error) {
1048
+ deps.recordRuntimeEvent?.("voice", error, { phase: "programmatic-handler-send" });
1049
+ }
1050
+ }
1051
+
1052
+ const providers = getTelegramVoiceSynthesisProviders();
1053
+
1054
+ for (const provider of providers) {
1055
+ let voiceFilePath: string | undefined;
1056
+ let originalFilePath: string | undefined;
1057
+
1058
+ try {
1059
+ if (typeof provider !== "function") {
1060
+ deps.recordRuntimeEvent?.(
1061
+ "voice",
1062
+ new Error(
1063
+ "Registered voice synthesis provider is not callable (policy-only object?)",
1064
+ ),
1065
+ { phase: "voice-provider-skip" },
1066
+ );
1067
+ continue;
1068
+ }
1069
+
1070
+ const providerResult = await provider(text, {
1071
+ lang: options?.lang,
1072
+ rate: options?.rate,
1073
+ });
1074
+
1075
+ if (!providerResult) {
1076
+ deps.recordRuntimeEvent?.(
1077
+ "voice",
1078
+ new Error("Voice synthesis provider returned empty path"),
1079
+ { phase: "voice-provider-skip" },
1080
+ );
1081
+ continue;
1082
+ }
1083
+
1084
+ const { filePath, transcriptText } = extractVoiceResult(providerResult);
1085
+ voiceFilePath = filePath;
1086
+ originalFilePath = filePath;
1087
+ await uploadVoiceFile(turn, filePath, {
1088
+ replyToPrompt: options?.replyToPrompt,
1089
+ replyMarkup: options?.replyMarkup,
1090
+ transcriptText,
1091
+ });
959
1092
  return;
960
1093
  } catch (error) {
961
1094
  deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
1095
+ } finally {
1096
+ if (voiceFilePath && voiceFilePath !== originalFilePath) {
1097
+ await unlink(voiceFilePath).catch(() => {});
1098
+ }
962
1099
  }
963
1100
  }
964
- await deps.sendTextReply?.(
965
- turn.chatId,
966
- turn.replyToMessageId,
967
- "Failed to send voice reply: every matching outbound voice handler failed.",
968
- );
1101
+
1102
+ const errorMessage =
1103
+ "Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.";
1104
+ deps.recordRuntimeEvent?.("voice", new Error(errorMessage), {
1105
+ phase: "send",
1106
+ });
1107
+ throw new Error(errorMessage);
969
1108
  };
970
1109
  }
971
1110
 
@@ -1024,6 +1163,92 @@ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
1024
1163
  return markdown.replace(/\n{3,}/g, "\n\n").trim();
1025
1164
  }
1026
1165
 
1166
+ export function replaceTopLevelHtmlComments(
1167
+ markdown: string,
1168
+ replacer: (comment: TelegramTopLevelHtmlComment) => string,
1169
+ ): string {
1170
+ const { comments } = collectTopLevelHtmlComments(markdown);
1171
+ if (comments.length === 0) return markdown;
1172
+ let result = "";
1173
+ let offset = 0;
1174
+ for (const comment of comments) {
1175
+ result += markdown.slice(offset, comment.start);
1176
+ result += replacer(comment);
1177
+ offset = comment.end;
1178
+ }
1179
+ return result + markdown.slice(offset);
1180
+ }
1181
+
1182
+ export function findTopLevelOpenOrPartialHtmlCommentIndex(
1183
+ markdown: string,
1184
+ ): number {
1185
+ const { openCommentStart } = collectTopLevelHtmlComments(markdown);
1186
+ if (openCommentStart !== undefined) return openCommentStart;
1187
+ let offset = 0;
1188
+ let fence: TelegramTopLevelFenceState | undefined;
1189
+ while (offset < markdown.length) {
1190
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
1191
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
1192
+ const isLastLine = lineEnd >= markdown.length;
1193
+ if (fence) {
1194
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
1195
+ offset = lineEnd;
1196
+ continue;
1197
+ }
1198
+ const nextFence = getTopLevelOpeningFence(line);
1199
+ if (nextFence) {
1200
+ fence = nextFence;
1201
+ offset = lineEnd;
1202
+ continue;
1203
+ }
1204
+ if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
1205
+ return offset;
1206
+ }
1207
+ offset = lineEnd;
1208
+ }
1209
+ return -1;
1210
+ }
1211
+
1212
+ export function parseTopLevelTelegramComment(
1213
+ comment: TelegramTopLevelHtmlComment,
1214
+ command: string,
1215
+ ): { head: string; body?: string } | undefined {
1216
+ let normalizedContent = comment.content.replace(/^\s+/, "");
1217
+ // Support both <!-- telegram_voice ... --> and <!--!telegram_voice ... --> forms
1218
+ normalizedContent = normalizedContent.replace(/^!/, "");
1219
+ const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
1220
+ let head = rawHead.trimStart();
1221
+ // Only tolerate the '!' prefix (used in <!--!telegram_voice ... --> form).
1222
+ // We intentionally do *not* do a broad strip of arbitrary non-letter characters
1223
+ // to preserve the "column-zero only" + "must start with telegram_voice" contract.
1224
+ if (!head.startsWith(command)) return undefined;
1225
+ const nextChar = head[command.length];
1226
+ if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
1227
+ return {
1228
+ head: head.slice(command.length),
1229
+ ...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
1230
+ };
1231
+ }
1232
+
1233
+ // --- Voice Comment Parsing Helpers ---
1234
+
1235
+ /**
1236
+ * Extracts label and prompt from a telegram_button comment string.
1237
+ */
1238
+ export function parseTelegramCommentAttributes(
1239
+ input: string,
1240
+ ): Record<string, string> {
1241
+ const attributes: Record<string, string> = {};
1242
+ for (const match of input.matchAll(
1243
+ /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
1244
+ )) {
1245
+ const key = match[1];
1246
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
1247
+ if (value) attributes[key] = value;
1248
+ }
1249
+ return attributes;
1250
+ }
1251
+
1027
1252
  function parseButtonsCommentAttributes(input: string): {
1028
1253
  label?: string;
1029
1254
  prompt?: string;
@@ -1035,11 +1260,16 @@ function parseButtonsCommentAttributes(input: string): {
1035
1260
  };
1036
1261
  }
1037
1262
 
1263
+ /**
1264
+ * Parses the content of a telegram_button comment into button rows.
1265
+ * Supports simple forms and forms with explicit label + prompt.
1266
+ */
1038
1267
  function parseButtonsCommentRows(
1039
1268
  head: string,
1040
1269
  body: string | undefined,
1041
1270
  ): TelegramOutboundButtonAction[][] {
1042
1271
  const trimmedHead = head.trim();
1272
+
1043
1273
  if (body === undefined) {
1044
1274
  if (trimmedHead.startsWith(":")) {
1045
1275
  const label = trimmedHead.slice(1).trim();
@@ -1050,12 +1280,170 @@ function parseButtonsCommentRows(
1050
1280
  ? [[{ text: attributes.label, prompt: attributes.prompt }]]
1051
1281
  : [];
1052
1282
  }
1283
+
1053
1284
  const label = parseButtonsCommentAttributes(head).label;
1054
1285
  const prompt = body.trim();
1055
1286
  if (!label || !prompt) return [];
1056
1287
  return [[{ text: label, prompt }]];
1057
1288
  }
1058
1289
 
1290
+ // --- Voice Reply Planning ---
1291
+
1292
+ // The generic comment parsing helpers (replaceTopLevelHtmlComments, etc.)
1293
+ // live locally in this file (used by both Voice and Button parsing).
1294
+
1295
+ export function normalizeMarkdownAfterVoiceExtraction(
1296
+ markdown: string,
1297
+ ): string {
1298
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
1299
+ }
1300
+
1301
+ function parseVoiceReplyAttributes(input: string): {
1302
+ lang?: string;
1303
+ rate?: string;
1304
+ text?: string;
1305
+ } {
1306
+ const attributes = parseTelegramCommentAttributes(input);
1307
+ return {
1308
+ ...(attributes.lang ? { lang: attributes.lang } : {}),
1309
+ ...(attributes.rate ? { rate: attributes.rate } : {}),
1310
+ ...(attributes.text ? { text: attributes.text } : {}),
1311
+ };
1312
+ }
1313
+
1314
+ function parseVoiceCommentBody(
1315
+ head: string,
1316
+ body: string | undefined,
1317
+ ): {
1318
+ attrs: string;
1319
+ text: string;
1320
+ } {
1321
+ const trimmedHead = head.trim();
1322
+ if (body !== undefined) {
1323
+ return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
1324
+ }
1325
+ // Always look for the first colon (that is not inside quotes) to separate attributes from text.
1326
+ // This handles both simple ": text" and "attributes: text" forms.
1327
+ let colonIndex = -1;
1328
+ let inQuote = false;
1329
+ let quoteChar = "";
1330
+ for (let i = 0; i < trimmedHead.length; i++) {
1331
+ const char = trimmedHead[i];
1332
+ if (inQuote) {
1333
+ if (char === quoteChar) inQuote = false;
1334
+ } else {
1335
+ if (char === '"' || char === "'") {
1336
+ inQuote = true;
1337
+ quoteChar = char;
1338
+ } else if (char === ":") {
1339
+ colonIndex = i;
1340
+ break;
1341
+ }
1342
+ }
1343
+ }
1344
+ if (colonIndex > 0) {
1345
+ const attrsPart = trimmedHead.slice(0, colonIndex).trim();
1346
+ const textPart = trimmedHead.slice(colonIndex + 1).trim();
1347
+ const attrs = parseVoiceReplyAttributes(attrsPart);
1348
+ return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
1349
+ }
1350
+ if (trimmedHead.startsWith(":")) {
1351
+ return { attrs: "", text: trimmedHead.slice(1).trim() };
1352
+ }
1353
+ const attrs = parseVoiceReplyAttributes(trimmedHead);
1354
+ return { attrs: trimmedHead, text: attrs.text ?? "" };
1355
+ }
1356
+
1357
+ export function stripTelegramCommentMarkupForPreview(markdown: string): string {
1358
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
1359
+ const openBlockIndex =
1360
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
1361
+ const previewMarkdown =
1362
+ openBlockIndex >= 0
1363
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
1364
+ : withoutClosedBlocks;
1365
+ return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
1366
+ }
1367
+
1368
+ export function stripTelegramCommentMarkupForDelivery(
1369
+ markdown: string,
1370
+ ): string {
1371
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
1372
+ const openBlockIndex =
1373
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
1374
+ const deliveryMarkdown =
1375
+ openBlockIndex >= 0
1376
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
1377
+ : withoutClosedBlocks;
1378
+ return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
1379
+ }
1380
+
1381
+ export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
1382
+ return stripTelegramCommentMarkupForPreview(markdown);
1383
+ }
1384
+
1385
+ /**
1386
+ * Parse a Markdown reply for `telegram_voice` blocks and build a voice reply plan.
1387
+ */
1388
+ export function planTelegramVoiceReply(
1389
+ markdown: string,
1390
+ ): TelegramVoiceReplyPlan {
1391
+ const voiceReplies: TelegramVoiceReplyItem[] = [];
1392
+ let lang: string | undefined;
1393
+ let rate: string | undefined;
1394
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
1395
+ let command = parseTopLevelTelegramComment(comment, "telegram_voice");
1396
+ if (!command) {
1397
+ // Robust fallback for Voice-specific comments.
1398
+ // Reached only for certain edge-case extractions from collectTopLevelHtmlComments
1399
+ // (e.g. comments with unusual leading characters or legacy forms that survive the
1400
+ // normalization in parseTopLevelTelegramComment but still contain "telegram_voice").
1401
+ // This path is intentionally narrow and not exercised by current documented usage.
1402
+ let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
1403
+ if (content.startsWith("telegram_voice")) {
1404
+ const headPart = content.slice("telegram_voice".length).trim();
1405
+ command = { head: headPart, body: undefined };
1406
+ }
1407
+ }
1408
+ if (!command) return "";
1409
+ const parsed = parseVoiceCommentBody(command.head, command.body);
1410
+ const attrs = parseVoiceReplyAttributes(parsed.attrs);
1411
+ if (parsed.text) {
1412
+ voiceReplies.push({
1413
+ text: parsed.text,
1414
+ ...(attrs.lang ? { lang: attrs.lang } : {}),
1415
+ ...(attrs.rate ? { rate: attrs.rate } : {}),
1416
+ });
1417
+ }
1418
+ if (attrs.lang) lang = attrs.lang;
1419
+ if (attrs.rate) rate = attrs.rate;
1420
+ return "";
1421
+ });
1422
+ const voiceText = voiceReplies
1423
+ .map((reply) => reply.text)
1424
+ .join("\n\n")
1425
+ .trim();
1426
+ return {
1427
+ markdown: stripTelegramCommentMarkupForDelivery(stripped),
1428
+ ...(voiceText ? { voiceText } : {}),
1429
+ ...(voiceReplies.length > 0 ? { voiceReplies } : {}),
1430
+ ...(lang ? { lang } : {}),
1431
+ ...(rate ? { rate } : {}),
1432
+ };
1433
+ }
1434
+
1435
+ // --- Button And Action Handling ---
1436
+
1437
+ /**
1438
+ * Handles assistant-authored buttons (<!-- telegram_button -->) and their callbacks.
1439
+ * Supports both simple buttons and buttons that enqueue a prompt when clicked.
1440
+ */
1441
+
1442
+ /**
1443
+ * Creates an in-memory store for button actions.
1444
+ * Buttons can be registered with a prompt that gets enqueued when the button is clicked.
1445
+ * Old actions are automatically cleaned up after the configured TTL.
1446
+ */
1059
1447
  export function createTelegramButtonActionStore(
1060
1448
  options: { ttlMs?: number } = {},
1061
1449
  ): TelegramButtonActionStore {
@@ -1070,22 +1458,33 @@ export function createTelegramButtonActionStore(
1070
1458
  register: (action) => {
1071
1459
  const currentTime = nowMs();
1072
1460
  cleanup(currentTime);
1461
+
1462
+ // Short random key for the callback_data (e.g. tgbtn:abcd1234)
1073
1463
  const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
1074
1464
  actions.set(key, { ...action, createdAt: currentTime });
1075
1465
  return key;
1076
1466
  },
1077
1467
  resolve: (callbackData) => {
1078
- if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`))
1468
+ if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1079
1469
  return undefined;
1470
+ }
1471
+
1080
1472
  const currentTime = nowMs();
1081
1473
  cleanup(currentTime);
1474
+
1082
1475
  const action = actions.get(callbackData);
1083
1476
  if (!action) return undefined;
1477
+
1084
1478
  return { text: action.text, prompt: action.prompt };
1085
1479
  },
1086
1480
  };
1087
1481
  }
1088
1482
 
1483
+ /**
1484
+ * Parses assistant markdown for `<!-- telegram_button -->` blocks
1485
+ * and builds a button plan (inline keyboard + registered actions).
1486
+ * Supports both simple label-only buttons and buttons with explicit prompts.
1487
+ */
1089
1488
  export function planTelegramButtonReply(
1090
1489
  markdown: string,
1091
1490
  deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
@@ -1113,6 +1512,10 @@ export function planTelegramButtonReply(
1113
1512
  };
1114
1513
  }
1115
1514
 
1515
+ /**
1516
+ * Creates a thin planner that combines `planTelegramButtonReply` with a given action store.
1517
+ * Mainly used to keep the call site clean when planning button replies from the artifact sender.
1518
+ */
1116
1519
  export function createTelegramButtonReplyPlanner(
1117
1520
  store: Pick<TelegramButtonActionStore, "register">,
1118
1521
  ): (markdown: string) => TelegramButtonReplyPlan {
@@ -1129,7 +1532,10 @@ export function createTelegramOutboundReplyPlanner(
1129
1532
  const buttonReply = planTelegramButtonReply(markdown, {
1130
1533
  registerAction: store.register,
1131
1534
  });
1535
+
1536
+ // Button replies can also contain <!-- telegram_voice --> markup
1132
1537
  const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
1538
+
1133
1539
  return {
1134
1540
  markdown: voiceReply.markdown,
1135
1541
  ...(buttonReply.replyMarkup
@@ -1145,6 +1551,15 @@ export function createTelegramOutboundReplyPlanner(
1145
1551
  };
1146
1552
  }
1147
1553
 
1554
+ /**
1555
+ * Create an artifact sender that delivers planned voice replies for a turn.
1556
+ * Iterates over `voiceReplies` (or a single `voiceText`) and sends each as
1557
+ * a Telegram voice message via the voice reply sender. Throws if no voice
1558
+ * reply could be delivered.
1559
+ */
1560
+
1561
+ // --- Outbound Reply Artifacts ---
1562
+
1148
1563
  export function createTelegramOutboundReplyArtifactSender(
1149
1564
  deps: TelegramVoiceReplySenderDeps,
1150
1565
  ) {
@@ -1153,21 +1568,38 @@ export function createTelegramOutboundReplyArtifactSender(
1153
1568
  turn: TelegramVoiceReplyTurnView,
1154
1569
  plan: Pick<
1155
1570
  TelegramOutboundReplyPlan,
1156
- "voiceText" | "voiceReplies" | "lang" | "rate"
1571
+ "voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
1157
1572
  >,
1158
1573
  options?: { replyToPrompt?: boolean },
1159
1574
  ): Promise<void> {
1575
+ // Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
1160
1576
  const voiceReplies = plan.voiceReplies?.length
1161
1577
  ? plan.voiceReplies
1162
1578
  : plan.voiceText
1163
1579
  ? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
1164
1580
  : [];
1165
- for (const [index, reply] of voiceReplies.entries()) {
1166
- await sendVoiceReply(turn, reply.text, {
1167
- lang: reply.lang ?? plan.lang,
1168
- rate: reply.rate ?? plan.rate,
1169
- replyToPrompt: options?.replyToPrompt === true && index === 0,
1170
- });
1581
+
1582
+ let anyDelivered = false;
1583
+
1584
+ for (const reply of voiceReplies) {
1585
+ try {
1586
+ await sendVoiceReply(turn, reply.text, {
1587
+ lang: reply.lang ?? plan.lang,
1588
+ rate: reply.rate ?? plan.rate,
1589
+ // Only attach reply parameters to the first voice message
1590
+ replyToPrompt: options?.replyToPrompt === true && !anyDelivered,
1591
+ replyMarkup: !anyDelivered ? plan.replyMarkup : undefined,
1592
+ });
1593
+ anyDelivered = true;
1594
+ } catch {
1595
+ // sendVoiceReply already recorded the error; continue to next reply
1596
+ }
1597
+ }
1598
+
1599
+ if (!anyDelivered) {
1600
+ throw new Error(
1601
+ "Failed to send voice reply: every voice synthesis provider failed.",
1602
+ );
1171
1603
  }
1172
1604
  };
1173
1605
  }
@@ -1196,12 +1628,19 @@ export function createTelegramButtonPromptTurn(options: {
1196
1628
  };
1197
1629
  }
1198
1630
 
1631
+ /**
1632
+ * Handles a button callback query.
1633
+ * Resolves the stored action, answers the callback, and enqueues the associated prompt if present.
1634
+ * Returns true if the query was handled by this system (even if the action had expired).
1635
+ */
1199
1636
  export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
1200
1637
  query: TelegramButtonCallbackQuery,
1201
1638
  ctx: TContext,
1202
1639
  deps: TelegramButtonCallbackHandlerDeps<TContext>,
1203
1640
  ): Promise<boolean> {
1204
1641
  const action = deps.resolveAction(query.data);
1642
+
1643
+ // Unknown / expired button (we only own tgbtn: keys)
1205
1644
  if (!action) {
1206
1645
  if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1207
1646
  await deps.answerCallbackQuery(query.id, "Button action expired.");
@@ -1209,12 +1648,15 @@ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
1209
1648
  }
1210
1649
  return false;
1211
1650
  }
1651
+
1652
+ // Invalid message context (should not happen for private chat buttons)
1212
1653
  const chatId = query.message?.chat?.id;
1213
1654
  const messageId = query.message?.message_id;
1214
1655
  if (typeof chatId !== "number" || typeof messageId !== "number") {
1215
1656
  await deps.answerCallbackQuery(query.id, "Button action expired.");
1216
1657
  return true;
1217
1658
  }
1659
+
1218
1660
  deps.enqueueButtonPrompt(query, action, ctx);
1219
1661
  await deps.answerCallbackQuery(query.id, "Queued.");
1220
1662
  return true;