@llblab/pi-telegram 0.5.2 → 0.6.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.
@@ -0,0 +1,899 @@
1
+ /**
2
+ * Telegram outbound handler helpers
3
+ * Owns assistant-authored outbound markup extraction, configured artifact generation, callback actions, and Telegram outbound delivery
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdir } from "node:fs/promises";
8
+ import { homedir } from "node:os";
9
+ import { basename, join, resolve } from "node:path";
10
+
11
+ import type { PendingTelegramTurn } from "./queue.ts";
12
+ import { buildTelegramMultipartReplyParameters } from "./replies.ts";
13
+ import { truncateTelegramQueueSummary } from "./turns.ts";
14
+ import {
15
+ buildCommandTemplateInvocation,
16
+ expandCommandTemplateConfigs,
17
+ substituteCommandTemplateToken,
18
+ type CommandTemplateObjectConfig,
19
+ } from "./command-templates.ts";
20
+
21
+ const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
22
+ const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
23
+ const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
24
+
25
+ export type TelegramOutboundCommandTemplateConfig =
26
+ | string
27
+ | CommandTemplateObjectConfig;
28
+ export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
29
+ type?: string;
30
+ match?: string | string[];
31
+ pipe?: TelegramOutboundCommandTemplateConfig[];
32
+ output?: string;
33
+ timeout?: number;
34
+ }
35
+
36
+ export interface TelegramVoiceReplyItem {
37
+ text: string;
38
+ lang?: string;
39
+ rate?: string;
40
+ }
41
+
42
+ export interface TelegramVoiceReplyPlan {
43
+ markdown: string;
44
+ voiceText?: string;
45
+ voiceReplies?: TelegramVoiceReplyItem[];
46
+ lang?: string;
47
+ rate?: string;
48
+ }
49
+
50
+ export interface TelegramVoiceExecOptions {
51
+ cwd?: string;
52
+ timeout?: number;
53
+ signal?: AbortSignal;
54
+ stdin?: string;
55
+ }
56
+
57
+ export interface TelegramVoiceExecResult {
58
+ stdout: string;
59
+ stderr: string;
60
+ code: number;
61
+ killed: boolean;
62
+ }
63
+
64
+ export interface TelegramVoiceReplyTurnView {
65
+ chatId: number;
66
+ replyToMessageId: number;
67
+ }
68
+
69
+ export interface TelegramVoiceReplySenderDeps {
70
+ execCommand: (
71
+ command: string,
72
+ args: string[],
73
+ options?: TelegramVoiceExecOptions,
74
+ ) => Promise<TelegramVoiceExecResult>;
75
+ sendMultipart: (
76
+ method: string,
77
+ fields: Record<string, string>,
78
+ fileField: string,
79
+ filePath: string,
80
+ fileName: string,
81
+ ) => Promise<unknown>;
82
+ sendTextReply?: (
83
+ chatId: number,
84
+ replyToMessageId: number,
85
+ text: string,
86
+ ) => Promise<unknown>;
87
+ getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
88
+ tempDir?: string;
89
+ cwd?: string;
90
+ recordRuntimeEvent?: (
91
+ category: string,
92
+ error: unknown,
93
+ details?: Record<string, unknown>,
94
+ ) => void;
95
+ }
96
+
97
+ interface TelegramTopLevelHtmlComment {
98
+ raw: string;
99
+ content: string;
100
+ start: number;
101
+ end: number;
102
+ }
103
+
104
+ interface TelegramTopLevelFenceState {
105
+ marker: "`" | "~";
106
+ length: number;
107
+ }
108
+
109
+ function getMarkdownLineEnd(markdown: string, offset: number): number {
110
+ const newlineIndex = markdown.indexOf("\n", offset);
111
+ return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
112
+ }
113
+
114
+ function getMarkdownLineText(
115
+ markdown: string,
116
+ offset: number,
117
+ end: number,
118
+ ): string {
119
+ return markdown.slice(offset, end).replace(/\r?\n$/, "");
120
+ }
121
+
122
+ function getTopLevelOpeningFence(
123
+ line: string,
124
+ ): TelegramTopLevelFenceState | undefined {
125
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
126
+ const sequence = match?.[1];
127
+ if (!sequence) return undefined;
128
+ return {
129
+ marker: sequence[0] as "`" | "~",
130
+ length: sequence.length,
131
+ };
132
+ }
133
+
134
+ function isTopLevelClosingFence(
135
+ line: string,
136
+ fence: TelegramTopLevelFenceState,
137
+ ): boolean {
138
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
139
+ const sequence = match?.[1];
140
+ return (
141
+ !!sequence &&
142
+ sequence[0] === fence.marker &&
143
+ sequence.length >= fence.length
144
+ );
145
+ }
146
+
147
+ function collectTopLevelHtmlComments(markdown: string): {
148
+ comments: TelegramTopLevelHtmlComment[];
149
+ openCommentStart?: number;
150
+ } {
151
+ const comments: TelegramTopLevelHtmlComment[] = [];
152
+ let offset = 0;
153
+ let fence: TelegramTopLevelFenceState | undefined;
154
+ while (offset < markdown.length) {
155
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
156
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
157
+ if (fence) {
158
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
159
+ offset = lineEnd;
160
+ continue;
161
+ }
162
+ const nextFence = getTopLevelOpeningFence(line);
163
+ if (nextFence) {
164
+ fence = nextFence;
165
+ offset = lineEnd;
166
+ continue;
167
+ }
168
+ if (line.startsWith("<!--")) {
169
+ const closeIndex = markdown.indexOf("-->", offset + 4);
170
+ if (closeIndex === -1) return { comments, openCommentStart: offset };
171
+ const end = closeIndex + 3;
172
+ const raw = markdown.slice(offset, end);
173
+ comments.push({ raw, content: raw.slice(4, -3), start: offset, end });
174
+ offset = getMarkdownLineEnd(markdown, end);
175
+ continue;
176
+ }
177
+ offset = lineEnd;
178
+ }
179
+ return { comments };
180
+ }
181
+
182
+ function replaceTopLevelHtmlComments(
183
+ markdown: string,
184
+ replacer: (comment: TelegramTopLevelHtmlComment) => string,
185
+ ): string {
186
+ const { comments } = collectTopLevelHtmlComments(markdown);
187
+ if (comments.length === 0) return markdown;
188
+ let result = "";
189
+ let offset = 0;
190
+ for (const comment of comments) {
191
+ result += markdown.slice(offset, comment.start);
192
+ result += replacer(comment);
193
+ offset = comment.end;
194
+ }
195
+ return result + markdown.slice(offset);
196
+ }
197
+
198
+ function findTopLevelOpenOrPartialHtmlCommentIndex(markdown: string): number {
199
+ const { openCommentStart } = collectTopLevelHtmlComments(markdown);
200
+ if (openCommentStart !== undefined) return openCommentStart;
201
+ let offset = 0;
202
+ let fence: TelegramTopLevelFenceState | undefined;
203
+ while (offset < markdown.length) {
204
+ const lineEnd = getMarkdownLineEnd(markdown, offset);
205
+ const line = getMarkdownLineText(markdown, offset, lineEnd);
206
+ const isLastLine = lineEnd >= markdown.length;
207
+ if (fence) {
208
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
209
+ offset = lineEnd;
210
+ continue;
211
+ }
212
+ const nextFence = getTopLevelOpeningFence(line);
213
+ if (nextFence) {
214
+ fence = nextFence;
215
+ offset = lineEnd;
216
+ continue;
217
+ }
218
+ if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
219
+ return offset;
220
+ }
221
+ offset = lineEnd;
222
+ }
223
+ return -1;
224
+ }
225
+
226
+ function parseTopLevelTelegramComment(
227
+ comment: TelegramTopLevelHtmlComment,
228
+ command: string,
229
+ ): { head: string; body?: string } | undefined {
230
+ const normalizedContent = comment.content.replace(/^\s+/, "");
231
+ const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
232
+ const head = rawHead.trimStart();
233
+ if (!head.startsWith(command)) return undefined;
234
+ const nextChar = head[command.length];
235
+ if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
236
+ return {
237
+ head: head.slice(command.length),
238
+ ...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
239
+ };
240
+ }
241
+
242
+ function parseVoiceReplyAttributes(input: string): {
243
+ lang?: string;
244
+ rate?: string;
245
+ } {
246
+ const attributes: { lang?: string; rate?: string } = {};
247
+ for (const token of input.trim().split(/\s+/).filter(Boolean)) {
248
+ const [rawKey, ...valueParts] = token.split("=");
249
+ const value = valueParts.join("=").trim();
250
+ if (rawKey === "lang" && value) attributes.lang = value;
251
+ if (rawKey === "rate" && value) attributes.rate = value;
252
+ }
253
+ return attributes;
254
+ }
255
+
256
+ function parseVoiceCommentBody(
257
+ head: string,
258
+ body: string | undefined,
259
+ ): {
260
+ attrs: string;
261
+ text: string;
262
+ } {
263
+ const trimmedHead = head.trim();
264
+ if (body !== undefined) {
265
+ return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
266
+ }
267
+ if (trimmedHead.startsWith(":")) {
268
+ return { attrs: "", text: trimmedHead.slice(1).trim() };
269
+ }
270
+ return { attrs: trimmedHead, text: "" };
271
+ }
272
+
273
+ function normalizeMarkdownAfterVoiceExtraction(markdown: string): string {
274
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
275
+ }
276
+
277
+ export function stripTelegramCommentMarkupForPreview(markdown: string): string {
278
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
279
+ const openBlockIndex =
280
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
281
+ const previewMarkdown =
282
+ openBlockIndex >= 0
283
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
284
+ : withoutClosedBlocks;
285
+ return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
286
+ }
287
+
288
+ export function stripTelegramCommentMarkupForDelivery(
289
+ markdown: string,
290
+ ): string {
291
+ const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
292
+ const openBlockIndex =
293
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
294
+ const deliveryMarkdown =
295
+ openBlockIndex >= 0
296
+ ? withoutClosedBlocks.slice(0, openBlockIndex)
297
+ : withoutClosedBlocks;
298
+ return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
299
+ }
300
+
301
+ export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
302
+ return stripTelegramCommentMarkupForPreview(markdown);
303
+ }
304
+
305
+ export function planTelegramVoiceReply(
306
+ markdown: string,
307
+ ): TelegramVoiceReplyPlan {
308
+ const voiceReplies: TelegramVoiceReplyItem[] = [];
309
+ let lang: string | undefined;
310
+ let rate: string | undefined;
311
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
312
+ const command = parseTopLevelTelegramComment(comment, "telegram_voice");
313
+ if (!command) return "";
314
+ const parsed = parseVoiceCommentBody(command.head, command.body);
315
+ const attrs = parseVoiceReplyAttributes(parsed.attrs);
316
+ if (parsed.text) {
317
+ voiceReplies.push({
318
+ text: parsed.text,
319
+ ...(attrs.lang ? { lang: attrs.lang } : {}),
320
+ ...(attrs.rate ? { rate: attrs.rate } : {}),
321
+ });
322
+ }
323
+ if (attrs.lang) lang = attrs.lang;
324
+ if (attrs.rate) rate = attrs.rate;
325
+ return "";
326
+ });
327
+ const voiceText = voiceReplies
328
+ .map((reply) => reply.text)
329
+ .join("\n\n")
330
+ .trim();
331
+ return {
332
+ markdown: stripTelegramCommentMarkupForDelivery(stripped),
333
+ ...(voiceText ? { voiceText } : {}),
334
+ ...(voiceReplies.length > 0 ? { voiceReplies } : {}),
335
+ ...(lang ? { lang } : {}),
336
+ ...(rate ? { rate } : {}),
337
+ };
338
+ }
339
+
340
+ function getVoiceReplyConfiguredTimeout(
341
+ config: TelegramOutboundCommandTemplateConfig | undefined,
342
+ ): number | undefined {
343
+ const timeout = typeof config === "string" ? undefined : config?.timeout;
344
+ return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
345
+ ? timeout
346
+ : undefined;
347
+ }
348
+
349
+ function getVoiceReplyTimeout(
350
+ config: TelegramOutboundCommandTemplateConfig | undefined,
351
+ ): number {
352
+ return getVoiceReplyConfiguredTimeout(config) ?? DEFAULT_VOICE_TIMEOUT_MS;
353
+ }
354
+
355
+ function getRemainingVoiceReplyTimeout(
356
+ timeout: number,
357
+ startedAt: number,
358
+ ): number {
359
+ return Math.max(1, timeout - (Date.now() - startedAt));
360
+ }
361
+
362
+ function getVoiceReplyCompositionStepTimeout(
363
+ handlerTimeout: number,
364
+ step: TelegramOutboundCommandTemplateConfig,
365
+ startedAt: number,
366
+ ): number {
367
+ const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
368
+ const stepTimeout = getVoiceReplyConfiguredTimeout(step);
369
+ return stepTimeout === undefined
370
+ ? remaining
371
+ : Math.min(stepTimeout, remaining);
372
+ }
373
+
374
+ function formatVoiceReplyExecutionFailure(
375
+ label: string,
376
+ result: TelegramVoiceExecResult,
377
+ ): string {
378
+ const parts = [
379
+ `${label} exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
380
+ ];
381
+ if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
382
+ if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
383
+ return parts.join("\n\n");
384
+ }
385
+
386
+ function extractVoiceReplyPath(stdout: string): string {
387
+ const path = stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
388
+ if (!path) throw new Error("Voice generator did not print an output path");
389
+ return path;
390
+ }
391
+
392
+ async function runVoiceReplyCommand(
393
+ label: string,
394
+ config: TelegramOutboundCommandTemplateConfig,
395
+ values: Record<string, string>,
396
+ options: {
397
+ cwd: string;
398
+ timeout: number;
399
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
400
+ stdin?: string;
401
+ },
402
+ ): Promise<TelegramVoiceExecResult> {
403
+ const invocation = buildCommandTemplateInvocation(
404
+ config,
405
+ values,
406
+ options.cwd,
407
+ {
408
+ emptyMessage: "Outbound voice template is empty",
409
+ missingLabel: "outbound voice template",
410
+ },
411
+ );
412
+ const result = await options.execCommand(
413
+ invocation.command,
414
+ invocation.args,
415
+ {
416
+ cwd: options.cwd,
417
+ timeout: options.timeout,
418
+ ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
419
+ },
420
+ );
421
+ if (result.code !== 0)
422
+ throw new Error(formatVoiceReplyExecutionFailure(label, result));
423
+ return result;
424
+ }
425
+
426
+ function getVoiceReplyOutputPath(
427
+ config: TelegramOutboundHandlerConfig,
428
+ values: Record<string, string>,
429
+ stdout: string,
430
+ ): string {
431
+ const output = config.output ?? "stdout";
432
+ if (output === "stdout") return extractVoiceReplyPath(stdout);
433
+ const keyMatch = output.match(/^\{?([A-Za-z_][A-Za-z0-9_-]*)\}?$/);
434
+ if (keyMatch && Object.hasOwn(values, keyMatch[1]))
435
+ return values[keyMatch[1]] ?? "";
436
+ return substituteCommandTemplateToken(
437
+ output,
438
+ values,
439
+ "outbound voice template",
440
+ );
441
+ }
442
+
443
+ function getVoiceReplyTemplateValues(
444
+ text: string,
445
+ options: { lang?: string; rate?: string; mp3Path: string; oggPath: string },
446
+ ): Record<string, string> {
447
+ return {
448
+ text,
449
+ mp3: options.mp3Path,
450
+ ogg: options.oggPath,
451
+ ...(options.lang ? { lang: options.lang } : {}),
452
+ ...(options.rate ? { rate: options.rate } : {}),
453
+ };
454
+ }
455
+
456
+ function getDefaultTelegramVoiceTempDir(): string {
457
+ const agentDir = process.env.PI_CODING_AGENT_DIR
458
+ ? resolve(process.env.PI_CODING_AGENT_DIR)
459
+ : join(homedir(), ".pi", "agent");
460
+ return join(agentDir, "tmp", "telegram");
461
+ }
462
+
463
+ function normalizeOutboundHandlerStringList(
464
+ value: string | string[] | undefined,
465
+ ): string[] {
466
+ if (Array.isArray(value))
467
+ return value
468
+ .map(String)
469
+ .map((item) => item.trim())
470
+ .filter(Boolean);
471
+ if (typeof value === "string" && value.trim()) return [value.trim()];
472
+ return [];
473
+ }
474
+
475
+ function outboundHandlerMatchesType(
476
+ handler: TelegramOutboundHandlerConfig,
477
+ type: string,
478
+ ): boolean {
479
+ const selectors = [
480
+ ...normalizeOutboundHandlerStringList(handler.type),
481
+ ...normalizeOutboundHandlerStringList(handler.match),
482
+ ];
483
+ if (selectors.length === 0) return false;
484
+ return selectors.includes(type);
485
+ }
486
+
487
+ export function findTelegramOutboundHandlers(
488
+ handlers: TelegramOutboundHandlerConfig[] | undefined,
489
+ type: string,
490
+ ): TelegramOutboundHandlerConfig[] {
491
+ if (!Array.isArray(handlers)) return [];
492
+ return handlers.filter(
493
+ (handler) =>
494
+ !!handler &&
495
+ typeof handler === "object" &&
496
+ outboundHandlerMatchesType(handler, type),
497
+ );
498
+ }
499
+
500
+ function getTelegramVoiceHandlerCompositionSteps(
501
+ handler: TelegramOutboundHandlerConfig,
502
+ ): TelegramOutboundCommandTemplateConfig[] {
503
+ if (Array.isArray(handler.template)) {
504
+ return expandCommandTemplateConfigs(
505
+ handler,
506
+ ) as TelegramOutboundCommandTemplateConfig[];
507
+ }
508
+ if (handler.pipe?.length) {
509
+ return expandCommandTemplateConfigs({
510
+ ...handler,
511
+ template: handler.pipe,
512
+ }) as TelegramOutboundCommandTemplateConfig[];
513
+ }
514
+ return [];
515
+ }
516
+
517
+ async function generateTelegramVoiceReplyFileWithHandler(
518
+ text: string,
519
+ options: {
520
+ lang?: string;
521
+ rate?: string;
522
+ handler: TelegramOutboundHandlerConfig;
523
+ tempDir: string;
524
+ cwd: string;
525
+ timeout: number;
526
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
527
+ },
528
+ ): Promise<string> {
529
+ await mkdir(options.tempDir, { recursive: true });
530
+ const artifactId = randomUUID();
531
+ const values = getVoiceReplyTemplateValues(text, {
532
+ lang: options.lang,
533
+ rate: options.rate,
534
+ mp3Path: join(options.tempDir, `${artifactId}-voice.mp3`),
535
+ oggPath: join(options.tempDir, `${artifactId}-voice.ogg`),
536
+ });
537
+ const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
538
+ if (steps.length > 0) {
539
+ const startedAt = Date.now();
540
+ let stdout = "";
541
+ for (const [index, step] of steps.entries()) {
542
+ const result = await runVoiceReplyCommand(
543
+ `Outbound voice template step ${index + 1}`,
544
+ step,
545
+ values,
546
+ {
547
+ cwd: options.cwd,
548
+ timeout: getVoiceReplyCompositionStepTimeout(
549
+ options.timeout,
550
+ step,
551
+ startedAt,
552
+ ),
553
+ execCommand: options.execCommand,
554
+ ...(index === 0 ? {} : { stdin: stdout }),
555
+ },
556
+ );
557
+ stdout = result.stdout;
558
+ }
559
+ return getVoiceReplyOutputPath(options.handler, values, stdout);
560
+ }
561
+ const result = await runVoiceReplyCommand(
562
+ "Outbound voice template",
563
+ options.handler,
564
+ values,
565
+ {
566
+ cwd: options.cwd,
567
+ timeout: options.timeout,
568
+ execCommand: options.execCommand,
569
+ },
570
+ );
571
+ return extractVoiceReplyPath(result.stdout);
572
+ }
573
+
574
+ export async function generateTelegramVoiceReplyFile(
575
+ text: string,
576
+ options: {
577
+ lang?: string;
578
+ rate?: string;
579
+ handler?: TelegramOutboundHandlerConfig;
580
+ tempDir?: string;
581
+ cwd?: string;
582
+ execCommand: TelegramVoiceReplySenderDeps["execCommand"];
583
+ },
584
+ ): Promise<string | undefined> {
585
+ const cwd = options.cwd ?? process.cwd();
586
+ const handler = options.handler;
587
+ if (!handler?.template && !handler?.pipe?.length) return undefined;
588
+ return generateTelegramVoiceReplyFileWithHandler(text, {
589
+ lang: options.lang,
590
+ rate: options.rate,
591
+ handler,
592
+ tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
593
+ cwd,
594
+ timeout: getVoiceReplyTimeout(handler),
595
+ execCommand: options.execCommand,
596
+ });
597
+ }
598
+
599
+ export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
600
+ markdown: string;
601
+ replyMarkup?: TReplyMarkup;
602
+ voiceText?: string;
603
+ voiceReplies?: TelegramVoiceReplyItem[];
604
+ lang?: string;
605
+ rate?: string;
606
+ }
607
+
608
+ export function createTelegramVoiceReplySender(
609
+ deps: TelegramVoiceReplySenderDeps,
610
+ ) {
611
+ return async function sendVoiceReply(
612
+ turn: TelegramVoiceReplyTurnView,
613
+ text: string,
614
+ options?: { lang?: string; rate?: string; replyToPrompt?: boolean },
615
+ ): Promise<void> {
616
+ const handlers = findTelegramOutboundHandlers(
617
+ deps.getHandlers?.(),
618
+ "voice",
619
+ );
620
+ if (handlers.length === 0) return;
621
+ for (const handler of handlers) {
622
+ try {
623
+ const filePath = await generateTelegramVoiceReplyFile(text, {
624
+ lang: options?.lang,
625
+ rate: options?.rate,
626
+ handler,
627
+ tempDir: deps.tempDir,
628
+ cwd: deps.cwd,
629
+ execCommand: deps.execCommand,
630
+ });
631
+ if (!filePath) continue;
632
+ const replyParameters = buildTelegramMultipartReplyParameters(
633
+ options?.replyToPrompt === false ? undefined : turn.replyToMessageId,
634
+ );
635
+ await deps.sendMultipart(
636
+ "sendVoice",
637
+ {
638
+ chat_id: String(turn.chatId),
639
+ ...(replyParameters ? { reply_parameters: replyParameters } : {}),
640
+ },
641
+ "voice",
642
+ filePath,
643
+ basename(filePath),
644
+ );
645
+ return;
646
+ } catch (error) {
647
+ deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
648
+ }
649
+ }
650
+ await deps.sendTextReply?.(
651
+ turn.chatId,
652
+ turn.replyToMessageId,
653
+ "Failed to send voice reply: every matching outbound voice handler failed.",
654
+ );
655
+ };
656
+ }
657
+
658
+ export interface TelegramOutboundButtonAction {
659
+ text: string;
660
+ prompt: string;
661
+ }
662
+
663
+ export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
664
+ createdAt: number;
665
+ }
666
+
667
+ export interface TelegramOutboundButtonMarkup {
668
+ inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
669
+ }
670
+
671
+ export interface TelegramButtonReplyPlan {
672
+ markdown: string;
673
+ replyMarkup?: TelegramOutboundButtonMarkup;
674
+ }
675
+
676
+ export interface TelegramButtonActionStore {
677
+ register: (action: TelegramOutboundButtonAction) => string;
678
+ resolve: (
679
+ callbackData: string | undefined,
680
+ ) => TelegramOutboundButtonAction | undefined;
681
+ }
682
+
683
+ export interface TelegramButtonCallbackQuery {
684
+ id: string;
685
+ data?: string;
686
+ message?: {
687
+ message_id?: number;
688
+ chat?: { id?: number };
689
+ };
690
+ }
691
+
692
+ export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
693
+ resolveAction: (
694
+ callbackData: string | undefined,
695
+ ) => TelegramOutboundButtonAction | undefined;
696
+ answerCallbackQuery: (
697
+ callbackQueryId: string,
698
+ text?: string,
699
+ ) => Promise<void>;
700
+ enqueueButtonPrompt: (
701
+ query: TelegramButtonCallbackQuery,
702
+ action: TelegramOutboundButtonAction,
703
+ ctx: TContext,
704
+ ) => void;
705
+ }
706
+
707
+ function nowMs(): number {
708
+ return Date.now();
709
+ }
710
+
711
+ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
712
+ return markdown.replace(/\n{3,}/g, "\n\n").trim();
713
+ }
714
+
715
+ function parseButtonsCommentAttributes(input: string): { label?: string } {
716
+ const attributes: { label?: string } = {};
717
+ for (const match of input.matchAll(
718
+ /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
719
+ )) {
720
+ const key = match[1];
721
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
722
+ if (key === "label" && value.trim()) attributes.label = value.trim();
723
+ }
724
+ return attributes;
725
+ }
726
+
727
+ function parseButtonsCommentRows(
728
+ head: string,
729
+ body: string | undefined,
730
+ ): TelegramOutboundButtonAction[][] {
731
+ const attributes = parseButtonsCommentAttributes(head);
732
+ if (!attributes.label) return [];
733
+ const prompt = body?.trim() || attributes.label;
734
+ return [[{ text: attributes.label, prompt }]];
735
+ }
736
+
737
+ export function createTelegramButtonActionStore(
738
+ options: { ttlMs?: number } = {},
739
+ ): TelegramButtonActionStore {
740
+ const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
741
+ const actions = new Map<string, TelegramOutboundButtonStoredAction>();
742
+ function cleanup(currentTime: number): void {
743
+ for (const [key, action] of actions) {
744
+ if (currentTime - action.createdAt > ttlMs) actions.delete(key);
745
+ }
746
+ }
747
+ return {
748
+ register: (action) => {
749
+ const currentTime = nowMs();
750
+ cleanup(currentTime);
751
+ const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
752
+ actions.set(key, { ...action, createdAt: currentTime });
753
+ return key;
754
+ },
755
+ resolve: (callbackData) => {
756
+ if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`))
757
+ return undefined;
758
+ const currentTime = nowMs();
759
+ cleanup(currentTime);
760
+ const action = actions.get(callbackData);
761
+ if (!action) return undefined;
762
+ return { text: action.text, prompt: action.prompt };
763
+ },
764
+ };
765
+ }
766
+
767
+ export function planTelegramButtonReply(
768
+ markdown: string,
769
+ deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
770
+ ): TelegramButtonReplyPlan {
771
+ const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
772
+ const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
773
+ const command = parseTopLevelTelegramComment(comment, "telegram_button");
774
+ if (!command) return comment.raw;
775
+ const rows = parseButtonsCommentRows(command.head, command.body);
776
+ for (const row of rows) {
777
+ keyboard.push(
778
+ row.map((button) => ({
779
+ text: button.text,
780
+ callback_data: deps.registerAction(button),
781
+ })),
782
+ );
783
+ }
784
+ return "";
785
+ });
786
+ return {
787
+ markdown: normalizeMarkdownAfterButtonExtraction(stripped),
788
+ ...(keyboard.length > 0
789
+ ? { replyMarkup: { inline_keyboard: keyboard } }
790
+ : {}),
791
+ };
792
+ }
793
+
794
+ export function createTelegramButtonReplyPlanner(
795
+ store: Pick<TelegramButtonActionStore, "register">,
796
+ ): (markdown: string) => TelegramButtonReplyPlan {
797
+ return (markdown) =>
798
+ planTelegramButtonReply(markdown, { registerAction: store.register });
799
+ }
800
+
801
+ export function createTelegramOutboundReplyPlanner(
802
+ store: Pick<TelegramButtonActionStore, "register">,
803
+ ): (
804
+ markdown: string,
805
+ ) => TelegramOutboundReplyPlan<TelegramOutboundButtonMarkup> {
806
+ return (markdown) => {
807
+ const buttonReply = planTelegramButtonReply(markdown, {
808
+ registerAction: store.register,
809
+ });
810
+ const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
811
+ return {
812
+ markdown: voiceReply.markdown,
813
+ ...(buttonReply.replyMarkup
814
+ ? { replyMarkup: buttonReply.replyMarkup }
815
+ : {}),
816
+ ...(voiceReply.voiceText ? { voiceText: voiceReply.voiceText } : {}),
817
+ ...(voiceReply.voiceReplies
818
+ ? { voiceReplies: voiceReply.voiceReplies }
819
+ : {}),
820
+ ...(voiceReply.lang ? { lang: voiceReply.lang } : {}),
821
+ ...(voiceReply.rate ? { rate: voiceReply.rate } : {}),
822
+ };
823
+ };
824
+ }
825
+
826
+ export function createTelegramOutboundReplyArtifactSender(
827
+ deps: TelegramVoiceReplySenderDeps,
828
+ ) {
829
+ const sendVoiceReply = createTelegramVoiceReplySender(deps);
830
+ return async function sendOutboundReplyArtifacts(
831
+ turn: TelegramVoiceReplyTurnView,
832
+ plan: Pick<
833
+ TelegramOutboundReplyPlan,
834
+ "voiceText" | "voiceReplies" | "lang" | "rate"
835
+ >,
836
+ options?: { replyToPrompt?: boolean },
837
+ ): Promise<void> {
838
+ const voiceReplies = plan.voiceReplies?.length
839
+ ? plan.voiceReplies
840
+ : plan.voiceText
841
+ ? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
842
+ : [];
843
+ for (const [index, reply] of voiceReplies.entries()) {
844
+ await sendVoiceReply(turn, reply.text, {
845
+ lang: reply.lang ?? plan.lang,
846
+ rate: reply.rate ?? plan.rate,
847
+ replyToPrompt: options?.replyToPrompt === true && index === 0,
848
+ });
849
+ }
850
+ };
851
+ }
852
+
853
+ export function createTelegramButtonPromptTurn(options: {
854
+ chatId: number;
855
+ replyToMessageId: number;
856
+ queueOrder: number;
857
+ action: TelegramOutboundButtonAction;
858
+ }): PendingTelegramTurn {
859
+ const prompt = `[telegram] ${options.action.prompt}`;
860
+ return {
861
+ kind: "prompt",
862
+ chatId: options.chatId,
863
+ replyToMessageId: options.replyToMessageId,
864
+ sourceMessageIds: [options.replyToMessageId],
865
+ queueOrder: options.queueOrder,
866
+ queueLane: "default",
867
+ laneOrder: options.queueOrder,
868
+ queuedAttachments: [],
869
+ content: [{ type: "text", text: prompt }],
870
+ historyText: options.action.prompt,
871
+ statusSummary: truncateTelegramQueueSummary(
872
+ options.action.text || options.action.prompt,
873
+ ),
874
+ };
875
+ }
876
+
877
+ export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
878
+ query: TelegramButtonCallbackQuery,
879
+ ctx: TContext,
880
+ deps: TelegramButtonCallbackHandlerDeps<TContext>,
881
+ ): Promise<boolean> {
882
+ const action = deps.resolveAction(query.data);
883
+ if (!action) {
884
+ if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
885
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
886
+ return true;
887
+ }
888
+ return false;
889
+ }
890
+ const chatId = query.message?.chat?.id;
891
+ const messageId = query.message?.message_id;
892
+ if (typeof chatId !== "number" || typeof messageId !== "number") {
893
+ await deps.answerCallbackQuery(query.id, "Button action expired.");
894
+ return true;
895
+ }
896
+ deps.enqueueButtonPrompt(query, action, ctx);
897
+ await deps.answerCallbackQuery(query.id, "Queued.");
898
+ return true;
899
+ }