@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
@@ -1,1663 +0,0 @@
1
- /**
2
- * Telegram outbound handler helpers
3
- * Zones: telegram outbound, assistant markup, command templates, callback routing
4
- * Owns assistant-authored outbound markup extraction, configured artifact generation, callback actions, and Telegram outbound delivery
5
- */
6
-
7
- import { randomUUID } from "node:crypto";
8
- import { mkdir, unlink } from "node:fs/promises";
9
- import { homedir } from "node:os";
10
- import { basename, extname, join, resolve } from "node:path";
11
-
12
- import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
- import type { PendingTelegramTurn } from "./queue.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
-
45
- import {
46
- buildCommandTemplateInvocation,
47
- expandCommandTemplateConfigs,
48
- type CommandTemplateObjectConfig,
49
- } from "./command-templates.ts";
50
- import { truncateTelegramQueueSummary } from "./queue.ts";
51
-
52
- const TELEGRAM_BUTTON_CALLBACK_PREFIX = "tgbtn";
53
- const TELEGRAM_BUTTON_ACTION_TTL_MS = 24 * 60 * 60 * 1000;
54
- const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
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
-
83
- export type TelegramOutboundCommandTemplateConfig =
84
- | string
85
- | CommandTemplateObjectConfig;
86
- export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
87
- type?: string;
88
- match?: string | string[];
89
- pipe?: TelegramOutboundCommandTemplateConfig[];
90
- output?: string;
91
- timeout?: number;
92
- }
93
-
94
- export interface TelegramVoiceReplyItem {
95
- text: string;
96
- lang?: string;
97
- rate?: string;
98
- }
99
-
100
- export interface TelegramVoiceReplyPlan {
101
- markdown: string;
102
- voiceText?: string;
103
- voiceReplies?: TelegramVoiceReplyItem[];
104
- lang?: string;
105
- rate?: string;
106
- }
107
-
108
- export interface TelegramVoiceExecOptions {
109
- cwd?: string;
110
- timeout?: number;
111
- signal?: AbortSignal;
112
- stdin?: string;
113
- retry?: number;
114
- }
115
-
116
- export interface TelegramVoiceExecResult {
117
- stdout: string;
118
- stderr: string;
119
- code: number;
120
- killed: boolean;
121
- }
122
-
123
- export interface TelegramVoiceReplyTurnView {
124
- chatId: number;
125
- replyToMessageId: number;
126
- }
127
-
128
- export interface TelegramVoiceReplySenderDeps {
129
- execCommand: (
130
- command: string,
131
- args: string[],
132
- options?: TelegramVoiceExecOptions,
133
- ) => Promise<TelegramVoiceExecResult>;
134
- sendMultipart: (
135
- method: string,
136
- fields: Record<string, string>,
137
- fileField: string,
138
- filePath: string,
139
- fileName: string,
140
- ) => Promise<unknown>;
141
- sendTextReply?: (
142
- chatId: number,
143
- replyToMessageId: number | undefined,
144
- text: string,
145
- options?: { parseMode?: "HTML" },
146
- ) => Promise<unknown>;
147
- sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
148
- sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
149
- getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
150
- cwd?: string;
151
- tempDir?: string;
152
- recordRuntimeEvent?: (
153
- category: string,
154
- error: unknown,
155
- details?: Record<string, unknown>,
156
- ) => void;
157
- }
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
-
224
- export interface TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup = unknown> {
225
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
226
- getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
227
- sendTextReply: (
228
- chatId: number,
229
- replyToMessageId: number | undefined,
230
- text: string,
231
- options?: { parseMode?: "HTML" },
232
- ) => Promise<number | undefined>;
233
- sendMarkdownReply: (
234
- chatId: number,
235
- replyToMessageId: number | undefined,
236
- markdown: string,
237
- options?: { replyMarkup?: TReplyMarkup },
238
- ) => Promise<number | undefined>;
239
- cwd?: string;
240
- recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
241
- }
242
-
243
- export interface TelegramInlineKeyboardLike {
244
- inline_keyboard: Array<Array<{ text: string; callback_data: string }>>;
245
- }
246
-
247
- export interface TelegramOutboundTextTransformOptions<TReplyMarkup = unknown> {
248
- handlers?: TelegramOutboundHandlerConfig[];
249
- cwd?: string;
250
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
251
- recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
252
- replyMarkup?: TReplyMarkup;
253
- }
254
-
255
- export interface TelegramOutboundTextTransformResult<TReplyMarkup = unknown> {
256
- text: string;
257
- replyMarkup?: TReplyMarkup;
258
- }
259
-
260
- export interface TelegramOutboundTextPreviewRuntimeDeps<
261
- TReplyMarkup = unknown,
262
- > {
263
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
264
- getHandlers?: () => TelegramOutboundHandlerConfig[] | undefined;
265
- finalizeMarkdownPreview: (
266
- chatId: number,
267
- markdown: string,
268
- replyToMessageId: number,
269
- options?: { replyMarkup?: TReplyMarkup },
270
- ) => Promise<boolean>;
271
- cwd?: string;
272
- recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
273
- }
274
-
275
- interface TelegramTopLevelHtmlComment {
276
- raw: string;
277
- content: string;
278
- start: number;
279
- end: number;
280
- }
281
-
282
- interface TelegramTopLevelFenceState {
283
- marker: "`" | "~";
284
- length: number;
285
- }
286
-
287
- function isTelegramActionCommentContent(content: string): boolean {
288
- const normalizedContent = content.replace(/^\s+/, "");
289
- const [head = ""] = normalizedContent.split(/\r?\n/, 1);
290
- return ["telegram_voice", "telegram_button"].some((command) => {
291
- if (!head.startsWith(command)) return false;
292
- const nextChar = head[command.length];
293
- return nextChar === undefined || /\s|:/.test(nextChar);
294
- });
295
- }
296
-
297
- function getMarkdownLineEnd(markdown: string, offset: number): number {
298
- const newlineIndex = markdown.indexOf("\n", offset);
299
- return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
300
- }
301
-
302
- function getMarkdownLineText(
303
- markdown: string,
304
- offset: number,
305
- end: number,
306
- ): string {
307
- return markdown.slice(offset, end).replace(/\r?\n$/, "");
308
- }
309
-
310
- function getTopLevelOpeningFence(
311
- line: string,
312
- ): TelegramTopLevelFenceState | undefined {
313
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
314
- const sequence = match?.[1];
315
- if (!sequence) return undefined;
316
- return {
317
- marker: sequence[0] as "`" | "~",
318
- length: sequence.length,
319
- };
320
- }
321
-
322
- function isTopLevelClosingFence(
323
- line: string,
324
- fence: TelegramTopLevelFenceState,
325
- ): boolean {
326
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
327
- const sequence = match?.[1];
328
- return (
329
- !!sequence &&
330
- sequence[0] === fence.marker &&
331
- sequence.length >= fence.length
332
- );
333
- }
334
-
335
- function collectInlineClosedTelegramActionBody(
336
- markdown: string,
337
- bodyStart: number,
338
- commentContent: string,
339
- ): { content: string; end: number } | undefined {
340
- const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
341
- const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
342
- const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
343
- const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
344
- const hasRecoverableBody =
345
- isTelegramActionCommentContent(commentContent) &&
346
- bodyLine.trim() !== "" &&
347
- !bodyLine.startsWith("<!--") &&
348
- !bodyLine.startsWith("-->") &&
349
- closeLine === "-->";
350
- if (!hasRecoverableBody) return undefined;
351
- return {
352
- content: `${commentContent.trimEnd()}\n${bodyLine}`,
353
- end: bodyLineEnd + 3,
354
- };
355
- }
356
-
357
- function collectTopLevelHtmlComments(markdown: string): {
358
- comments: TelegramTopLevelHtmlComment[];
359
- openCommentStart?: number;
360
- } {
361
- const comments: TelegramTopLevelHtmlComment[] = [];
362
- let offset = 0;
363
- let fence: TelegramTopLevelFenceState | undefined;
364
- while (offset < markdown.length) {
365
- const lineEnd = getMarkdownLineEnd(markdown, offset);
366
- const line = getMarkdownLineText(markdown, offset, lineEnd);
367
- if (fence) {
368
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
369
- offset = lineEnd;
370
- continue;
371
- }
372
- const nextFence = getTopLevelOpeningFence(line);
373
- if (nextFence) {
374
- fence = nextFence;
375
- offset = lineEnd;
376
- continue;
377
- }
378
- if (line.startsWith("<!--")) {
379
- const closeIndex = markdown.indexOf("-->", offset + 4);
380
- if (closeIndex === -1) return { comments, openCommentStart: offset };
381
- let end = closeIndex + 3;
382
- let raw = markdown.slice(offset, end);
383
- let content = raw.slice(4, -3);
384
- const closeColumn = closeIndex - offset;
385
- const closesOnOpeningLine = closeIndex < lineEnd;
386
- const hasOnlyWhitespaceAfterClose =
387
- line.slice(closeColumn + 3).trim() === "";
388
- const inlineBody =
389
- closesOnOpeningLine && hasOnlyWhitespaceAfterClose
390
- ? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
391
- : undefined;
392
- if (inlineBody) {
393
- end = inlineBody.end;
394
- raw = markdown.slice(offset, end);
395
- content = inlineBody.content;
396
- }
397
- comments.push({ raw, content, start: offset, end });
398
- offset = getMarkdownLineEnd(markdown, end);
399
- continue;
400
- }
401
- offset = lineEnd;
402
- }
403
- return { comments };
404
- }
405
-
406
- // --- Voice Delivery Helpers ---
407
-
408
- function extractVoiceResult(result: any): {
409
- filePath: string;
410
- transcriptText?: string;
411
- } {
412
- if (typeof result === "string") {
413
- return { filePath: result };
414
- }
415
- return {
416
- filePath: result.audioPath,
417
- transcriptText: result.transcriptText,
418
- };
419
- }
420
-
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(() => {});
429
- }
430
- }
431
-
432
- // --- Voice Reply Timeout Helpers ---
433
-
434
- function getVoiceReplyConfiguredTimeout(
435
- config: TelegramOutboundCommandTemplateConfig | undefined,
436
- ): number | undefined {
437
- const timeout = typeof config === "string" ? undefined : config?.timeout;
438
- return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
439
- ? timeout
440
- : undefined;
441
- }
442
-
443
- function getVoiceReplyTimeout(
444
- config: TelegramOutboundCommandTemplateConfig | undefined,
445
- ): number {
446
- return getVoiceReplyConfiguredTimeout(config) ?? DEFAULT_VOICE_TIMEOUT_MS;
447
- }
448
-
449
- function getRemainingVoiceReplyTimeout(
450
- timeout: number,
451
- startedAt: number,
452
- ): number {
453
- return Math.max(1, timeout - (Date.now() - startedAt));
454
- }
455
-
456
- function getVoiceReplyCompositionStepTimeout(
457
- handlerTimeout: number,
458
- step: TelegramOutboundCommandTemplateConfig,
459
- startedAt: number,
460
- ): number {
461
- const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
462
- const stepTimeout = getVoiceReplyConfiguredTimeout(step);
463
- return stepTimeout === undefined
464
- ? remaining
465
- : Math.min(stepTimeout, remaining);
466
- }
467
-
468
- function formatVoiceReplyExecutionFailure(
469
- label: string,
470
- result: TelegramVoiceExecResult,
471
- ): string {
472
- const parts = [
473
- `${label} exited with code ${result.code}${result.killed ? " (killed)" : ""}`,
474
- ];
475
- if (result.stderr.trim()) parts.push(`stderr:\n${result.stderr.trimEnd()}`);
476
- if (result.stdout.trim()) parts.push(`stdout:\n${result.stdout.trimEnd()}`);
477
- return parts.join("\n\n");
478
- }
479
-
480
- async function runVoiceReplyCommand(
481
- label: string,
482
- config: TelegramOutboundCommandTemplateConfig,
483
- values: Record<string, string>,
484
- options: {
485
- cwd: string;
486
- timeout: number;
487
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
488
- stdin?: string;
489
- },
490
- ): Promise<TelegramVoiceExecResult> {
491
- if (!options.execCommand) {
492
- throw new Error("execCommand is required for command template execution");
493
- }
494
- const invocation = buildCommandTemplateInvocation(
495
- config,
496
- values,
497
- options.cwd,
498
- {
499
- emptyMessage: "Outbound voice template is empty",
500
- missingLabel: "outbound voice template",
501
- },
502
- );
503
- const result = await options.execCommand(
504
- invocation.command,
505
- invocation.args,
506
- {
507
- cwd: options.cwd,
508
- timeout: options.timeout,
509
- ...(typeof config === "object" && config.retry !== undefined
510
- ? { retry: config.retry }
511
- : {}),
512
- ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
513
- },
514
- );
515
- if (result.code !== 0)
516
- throw new Error(formatVoiceReplyExecutionFailure(label, result));
517
- return result;
518
- }
519
-
520
- function normalizeOutboundHandlerStringList(
521
- value: string | string[] | undefined,
522
- ): string[] {
523
- if (Array.isArray(value))
524
- return value
525
- .map(String)
526
- .map((item) => item.trim())
527
- .filter(Boolean);
528
- if (typeof value === "string" && value.trim()) return [value.trim()];
529
- return [];
530
- }
531
-
532
- function outboundHandlerMatchesType(
533
- handler: TelegramOutboundHandlerConfig,
534
- type: string,
535
- ): boolean {
536
- const selectors = [
537
- ...normalizeOutboundHandlerStringList(handler.type),
538
- ...normalizeOutboundHandlerStringList(handler.match),
539
- ];
540
- if (selectors.length === 0) return false;
541
- return selectors.includes(type);
542
- }
543
-
544
- export function findTelegramOutboundHandlers(
545
- handlers: TelegramOutboundHandlerConfig[] | undefined,
546
- type: string,
547
- ): TelegramOutboundHandlerConfig[] {
548
- if (!Array.isArray(handlers)) return [];
549
- return handlers.filter(
550
- (handler) =>
551
- !!handler &&
552
- typeof handler === "object" &&
553
- outboundHandlerMatchesType(handler, type),
554
- );
555
- }
556
-
557
- function getTelegramVoiceHandlerCompositionSteps(
558
- handler: TelegramOutboundHandlerConfig,
559
- ): TelegramOutboundCommandTemplateConfig[] {
560
- if (Array.isArray(handler.template)) {
561
- return expandCommandTemplateConfigs(
562
- handler,
563
- ) as TelegramOutboundCommandTemplateConfig[];
564
- }
565
- if (handler.pipe?.length) {
566
- return expandCommandTemplateConfigs({
567
- ...handler,
568
- template: handler.pipe,
569
- }) as TelegramOutboundCommandTemplateConfig[];
570
- }
571
- return [];
572
- }
573
-
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
- });
638
- const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
639
- if (steps.length > 0) {
640
- const startedAt = Date.now();
641
- let stdout = text;
642
- for (const [index, step] of steps.entries()) {
643
- try {
644
- const result = await runVoiceReplyCommand(
645
- `Outbound voice template step ${index + 1}`,
646
- step,
647
- values,
648
- {
649
- cwd: options.cwd,
650
- timeout: getVoiceReplyCompositionStepTimeout(
651
- options.timeout,
652
- step,
653
- startedAt,
654
- ),
655
- execCommand: options.execCommand,
656
- stdin: stdout,
657
- },
658
- );
659
- stdout = result.stdout;
660
- } catch (error) {
661
- if (typeof step === "object" && step.critical) throw error;
662
- stdout = "";
663
- }
664
- }
665
- return getVoiceReplyOutputPath(options.handler, values, stdout);
666
- }
667
- const result = await runVoiceReplyCommand(
668
- "Outbound voice template",
669
- options.handler,
670
- values,
671
- {
672
- cwd: options.cwd,
673
- timeout: options.timeout,
674
- execCommand: options.execCommand,
675
- stdin: text,
676
- },
677
- );
678
- return getVoiceReplyOutputPath(options.handler, values, result.stdout);
679
- }
680
-
681
- export async function generateTelegramVoiceReplyFile(
682
- text: string,
683
- options: {
684
- lang?: string;
685
- rate?: string;
686
- handler?: TelegramOutboundHandlerConfig;
687
- tempDir?: string;
688
- cwd?: string;
689
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
690
- },
691
- ): Promise<string | undefined> {
692
- const handler = options.handler;
693
- if (!handler?.template && !handler?.pipe?.length) return undefined;
694
- return generateTelegramVoiceReplyFileWithHandler(text, {
695
- lang: options.lang,
696
- rate: options.rate,
697
- handler,
698
- tempDir: options.tempDir ?? getDefaultTelegramVoiceTempDir(),
699
- cwd: options.cwd ?? process.cwd(),
700
- timeout: getVoiceReplyTimeout(handler),
701
- execCommand: options.execCommand,
702
- });
703
- }
704
-
705
- function getOutboundTextTemplateValues(text: string): Record<string, string> {
706
- return { text, type: "text" };
707
- }
708
-
709
- async function transformTelegramOutboundTextWithHandler(
710
- text: string,
711
- options: {
712
- handler: TelegramOutboundHandlerConfig;
713
- cwd: string;
714
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
715
- },
716
- ): Promise<string> {
717
- const values = getOutboundTextTemplateValues(text);
718
- const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
719
- if (steps.length > 0) {
720
- const startedAt = Date.now();
721
- let stdout = text;
722
- for (const [index, step] of steps.entries()) {
723
- try {
724
- const result = await runVoiceReplyCommand(
725
- `Outbound text template step ${index + 1}`,
726
- step,
727
- values,
728
- {
729
- cwd: options.cwd,
730
- timeout: getVoiceReplyCompositionStepTimeout(
731
- getVoiceReplyTimeout(options.handler),
732
- step,
733
- startedAt,
734
- ),
735
- execCommand: options.execCommand,
736
- stdin: stdout,
737
- },
738
- );
739
- stdout = result.stdout;
740
- } catch (error) {
741
- if (typeof step === "object" && step.critical) throw error;
742
- stdout = "";
743
- }
744
- if (!stdout) stdout = text;
745
- }
746
- return stdout.trim() || text;
747
- }
748
- const result = await runVoiceReplyCommand(
749
- "Outbound text template",
750
- options.handler,
751
- values,
752
- {
753
- cwd: options.cwd,
754
- timeout: getVoiceReplyTimeout(options.handler),
755
- execCommand: options.execCommand,
756
- stdin: text,
757
- },
758
- );
759
- return result.stdout.trim() || text;
760
- }
761
-
762
- export async function transformTelegramOutboundText(
763
- text: string,
764
- options: {
765
- handlers?: TelegramOutboundHandlerConfig[];
766
- cwd?: string;
767
- execCommand: TelegramVoiceReplySenderDeps["execCommand"];
768
- recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
769
- },
770
- ): Promise<string> {
771
- let transformed = text;
772
- for (const handler of findTelegramOutboundHandlers(
773
- options.handlers,
774
- "text",
775
- )) {
776
- try {
777
- transformed = await transformTelegramOutboundTextWithHandler(
778
- transformed,
779
- {
780
- handler,
781
- cwd: options.cwd ?? process.cwd(),
782
- execCommand: options.execCommand,
783
- },
784
- );
785
- } catch (error) {
786
- options.recordRuntimeEvent?.("outbound-text-handler", error, {
787
- handler: outboundHandlerMatchesType(handler, "text")
788
- ? "text"
789
- : "unknown",
790
- });
791
- }
792
- }
793
- return transformed;
794
- }
795
-
796
- function isTelegramInlineKeyboardLike(
797
- replyMarkup: unknown,
798
- ): replyMarkup is TelegramInlineKeyboardLike {
799
- if (!replyMarkup || typeof replyMarkup !== "object") return false;
800
- const keyboard = (replyMarkup as { inline_keyboard?: unknown })
801
- .inline_keyboard;
802
- return Array.isArray(keyboard);
803
- }
804
-
805
- async function transformTelegramOutboundReplyMarkup<TReplyMarkup>(
806
- replyMarkup: TReplyMarkup | undefined,
807
- options: Omit<TelegramOutboundTextTransformOptions, "replyMarkup">,
808
- ): Promise<TReplyMarkup | undefined> {
809
- if (!isTelegramInlineKeyboardLike(replyMarkup)) return replyMarkup;
810
- const translatedRows = [];
811
- for (const row of replyMarkup.inline_keyboard) {
812
- const translatedRow = [];
813
- for (const button of row) {
814
- const text = await transformTelegramOutboundText(button.text, options);
815
- translatedRow.push({ ...button, text });
816
- }
817
- translatedRows.push(translatedRow);
818
- }
819
- return { ...replyMarkup, inline_keyboard: translatedRows } as TReplyMarkup;
820
- }
821
-
822
- export async function transformTelegramOutboundTextReply<
823
- TReplyMarkup = unknown,
824
- >(
825
- text: string,
826
- options: TelegramOutboundTextTransformOptions<TReplyMarkup>,
827
- ): Promise<TelegramOutboundTextTransformResult<TReplyMarkup>> {
828
- const transformOptions = {
829
- handlers: options.handlers,
830
- cwd: options.cwd,
831
- execCommand: options.execCommand,
832
- recordRuntimeEvent: options.recordRuntimeEvent,
833
- };
834
- const transformedText = await transformTelegramOutboundText(
835
- text,
836
- transformOptions,
837
- );
838
- const replyMarkup = await transformTelegramOutboundReplyMarkup(
839
- options.replyMarkup,
840
- transformOptions,
841
- );
842
- return { text: transformedText, ...(replyMarkup ? { replyMarkup } : {}) };
843
- }
844
-
845
- export function createTelegramOutboundTextReplyRuntime<TReplyMarkup = unknown>(
846
- deps: TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
847
- ): Pick<
848
- TelegramOutboundTextReplyRuntimeDeps<TReplyMarkup>,
849
- "sendTextReply" | "sendMarkdownReply"
850
- > {
851
- return {
852
- sendTextReply: async (chatId, replyToMessageId, text, options) => {
853
- const transformed = await transformTelegramOutboundText(text, {
854
- handlers: deps.getHandlers?.(),
855
- cwd: deps.cwd,
856
- execCommand: deps.execCommand,
857
- recordRuntimeEvent: deps.recordRuntimeEvent,
858
- });
859
- return deps.sendTextReply(chatId, replyToMessageId, transformed, options);
860
- },
861
- sendMarkdownReply: async (chatId, replyToMessageId, markdown, options) => {
862
- const transformed = await transformTelegramOutboundTextReply(markdown, {
863
- handlers: deps.getHandlers?.(),
864
- cwd: deps.cwd,
865
- execCommand: deps.execCommand,
866
- recordRuntimeEvent: deps.recordRuntimeEvent,
867
- replyMarkup: options?.replyMarkup,
868
- });
869
- return deps.sendMarkdownReply(
870
- chatId,
871
- replyToMessageId,
872
- transformed.text,
873
- {
874
- ...options,
875
- ...(transformed.replyMarkup
876
- ? { replyMarkup: transformed.replyMarkup }
877
- : {}),
878
- },
879
- );
880
- },
881
- };
882
- }
883
-
884
- export function createTelegramOutboundTextPreviewRuntime<
885
- TReplyMarkup = unknown,
886
- >(
887
- deps: TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
888
- ): Pick<
889
- TelegramOutboundTextPreviewRuntimeDeps<TReplyMarkup>,
890
- "finalizeMarkdownPreview"
891
- > {
892
- return {
893
- finalizeMarkdownPreview: async (
894
- chatId,
895
- markdown,
896
- replyToMessageId,
897
- options,
898
- ) => {
899
- const transformed = await transformTelegramOutboundTextReply(markdown, {
900
- handlers: deps.getHandlers?.(),
901
- cwd: deps.cwd,
902
- execCommand: deps.execCommand,
903
- recordRuntimeEvent: deps.recordRuntimeEvent,
904
- replyMarkup: options?.replyMarkup,
905
- });
906
- return deps.finalizeMarkdownPreview(
907
- chatId,
908
- transformed.text,
909
- replyToMessageId,
910
- {
911
- ...options,
912
- ...(transformed.replyMarkup
913
- ? { replyMarkup: transformed.replyMarkup }
914
- : {}),
915
- },
916
- );
917
- },
918
- };
919
- }
920
-
921
- export interface TelegramOutboundReplyPlan<TReplyMarkup = unknown> {
922
- markdown: string;
923
- replyMarkup?: TReplyMarkup;
924
- voiceText?: string;
925
- voiceReplies?: TelegramVoiceReplyItem[];
926
- lang?: string;
927
- rate?: string;
928
- }
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
- */
962
- export function createTelegramVoiceReplySender(
963
- deps: TelegramVoiceReplySenderDeps,
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
-
1001
- return async function sendVoiceReply(
1002
- turn: TelegramVoiceReplyTurnView,
1003
- text: string,
1004
- options?: {
1005
- lang?: string;
1006
- rate?: string;
1007
- replyToPrompt?: boolean;
1008
- replyMarkup?: unknown;
1009
- },
1010
- ): Promise<void> {
1011
- for (const handler of findTelegramOutboundHandlers(
1012
- deps.getHandlers?.(),
1013
- "voice",
1014
- )) {
1015
- try {
1016
- const filePath = await generateTelegramVoiceReplyFile(text, {
1017
- lang: options?.lang,
1018
- rate: options?.rate,
1019
- handler,
1020
- tempDir: deps.tempDir,
1021
- cwd: deps.cwd,
1022
- execCommand: deps.execCommand,
1023
- });
1024
- if (!filePath) continue;
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
- });
1092
- return;
1093
- } catch (error) {
1094
- deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
1095
- } finally {
1096
- if (voiceFilePath && voiceFilePath !== originalFilePath) {
1097
- await unlink(voiceFilePath).catch(() => {});
1098
- }
1099
- }
1100
- }
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);
1108
- };
1109
- }
1110
-
1111
- export interface TelegramOutboundButtonAction {
1112
- text: string;
1113
- prompt: string;
1114
- }
1115
-
1116
- export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
1117
- createdAt: number;
1118
- }
1119
-
1120
- export type TelegramOutboundButtonMarkup = TelegramInlineKeyboardMarkup;
1121
-
1122
- export interface TelegramButtonReplyPlan {
1123
- markdown: string;
1124
- replyMarkup?: TelegramOutboundButtonMarkup;
1125
- }
1126
-
1127
- export interface TelegramButtonActionStore {
1128
- register: (action: TelegramOutboundButtonAction) => string;
1129
- resolve: (
1130
- callbackData: string | undefined,
1131
- ) => TelegramOutboundButtonAction | undefined;
1132
- }
1133
-
1134
- export interface TelegramButtonCallbackQuery {
1135
- id: string;
1136
- data?: string;
1137
- message?: {
1138
- message_id?: number;
1139
- chat?: { id?: number };
1140
- };
1141
- }
1142
-
1143
- export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
1144
- resolveAction: (
1145
- callbackData: string | undefined,
1146
- ) => TelegramOutboundButtonAction | undefined;
1147
- answerCallbackQuery: (
1148
- callbackQueryId: string,
1149
- text?: string,
1150
- ) => Promise<void>;
1151
- enqueueButtonPrompt: (
1152
- query: TelegramButtonCallbackQuery,
1153
- action: TelegramOutboundButtonAction,
1154
- ctx: TContext,
1155
- ) => void;
1156
- }
1157
-
1158
- function nowMs(): number {
1159
- return Date.now();
1160
- }
1161
-
1162
- function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
1163
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
1164
- }
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
-
1252
- function parseButtonsCommentAttributes(input: string): {
1253
- label?: string;
1254
- prompt?: string;
1255
- } {
1256
- const attributes = parseTelegramCommentAttributes(input);
1257
- return {
1258
- ...(attributes.label ? { label: attributes.label } : {}),
1259
- ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
1260
- };
1261
- }
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
- */
1267
- function parseButtonsCommentRows(
1268
- head: string,
1269
- body: string | undefined,
1270
- ): TelegramOutboundButtonAction[][] {
1271
- const trimmedHead = head.trim();
1272
-
1273
- if (body === undefined) {
1274
- if (trimmedHead.startsWith(":")) {
1275
- const label = trimmedHead.slice(1).trim();
1276
- return label ? [[{ text: label, prompt: label }]] : [];
1277
- }
1278
- const attributes = parseButtonsCommentAttributes(head);
1279
- return attributes.label && attributes.prompt
1280
- ? [[{ text: attributes.label, prompt: attributes.prompt }]]
1281
- : [];
1282
- }
1283
-
1284
- const label = parseButtonsCommentAttributes(head).label;
1285
- const prompt = body.trim();
1286
- if (!label || !prompt) return [];
1287
- return [[{ text: label, prompt }]];
1288
- }
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
- */
1447
- export function createTelegramButtonActionStore(
1448
- options: { ttlMs?: number } = {},
1449
- ): TelegramButtonActionStore {
1450
- const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
1451
- const actions = new Map<string, TelegramOutboundButtonStoredAction>();
1452
- function cleanup(currentTime: number): void {
1453
- for (const [key, action] of actions) {
1454
- if (currentTime - action.createdAt > ttlMs) actions.delete(key);
1455
- }
1456
- }
1457
- return {
1458
- register: (action) => {
1459
- const currentTime = nowMs();
1460
- cleanup(currentTime);
1461
-
1462
- // Short random key for the callback_data (e.g. tgbtn:abcd1234)
1463
- const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
1464
- actions.set(key, { ...action, createdAt: currentTime });
1465
- return key;
1466
- },
1467
- resolve: (callbackData) => {
1468
- if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1469
- return undefined;
1470
- }
1471
-
1472
- const currentTime = nowMs();
1473
- cleanup(currentTime);
1474
-
1475
- const action = actions.get(callbackData);
1476
- if (!action) return undefined;
1477
-
1478
- return { text: action.text, prompt: action.prompt };
1479
- },
1480
- };
1481
- }
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
- */
1488
- export function planTelegramButtonReply(
1489
- markdown: string,
1490
- deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
1491
- ): TelegramButtonReplyPlan {
1492
- const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
1493
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
1494
- const command = parseTopLevelTelegramComment(comment, "telegram_button");
1495
- if (!command) return comment.raw;
1496
- const rows = parseButtonsCommentRows(command.head, command.body);
1497
- for (const row of rows) {
1498
- keyboard.push(
1499
- row.map((button) => ({
1500
- text: button.text,
1501
- callback_data: deps.registerAction(button),
1502
- })),
1503
- );
1504
- }
1505
- return "";
1506
- });
1507
- return {
1508
- markdown: normalizeMarkdownAfterButtonExtraction(stripped),
1509
- ...(keyboard.length > 0
1510
- ? { replyMarkup: { inline_keyboard: keyboard } }
1511
- : {}),
1512
- };
1513
- }
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
- */
1519
- export function createTelegramButtonReplyPlanner(
1520
- store: Pick<TelegramButtonActionStore, "register">,
1521
- ): (markdown: string) => TelegramButtonReplyPlan {
1522
- return (markdown) =>
1523
- planTelegramButtonReply(markdown, { registerAction: store.register });
1524
- }
1525
-
1526
- export function createTelegramOutboundReplyPlanner(
1527
- store: Pick<TelegramButtonActionStore, "register">,
1528
- ): (
1529
- markdown: string,
1530
- ) => TelegramOutboundReplyPlan<TelegramOutboundButtonMarkup> {
1531
- return (markdown) => {
1532
- const buttonReply = planTelegramButtonReply(markdown, {
1533
- registerAction: store.register,
1534
- });
1535
-
1536
- // Button replies can also contain <!-- telegram_voice --> markup
1537
- const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
1538
-
1539
- return {
1540
- markdown: voiceReply.markdown,
1541
- ...(buttonReply.replyMarkup
1542
- ? { replyMarkup: buttonReply.replyMarkup }
1543
- : {}),
1544
- ...(voiceReply.voiceText ? { voiceText: voiceReply.voiceText } : {}),
1545
- ...(voiceReply.voiceReplies
1546
- ? { voiceReplies: voiceReply.voiceReplies }
1547
- : {}),
1548
- ...(voiceReply.lang ? { lang: voiceReply.lang } : {}),
1549
- ...(voiceReply.rate ? { rate: voiceReply.rate } : {}),
1550
- };
1551
- };
1552
- }
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
-
1563
- export function createTelegramOutboundReplyArtifactSender(
1564
- deps: TelegramVoiceReplySenderDeps,
1565
- ) {
1566
- const sendVoiceReply = createTelegramVoiceReplySender(deps);
1567
- return async function sendOutboundReplyArtifacts(
1568
- turn: TelegramVoiceReplyTurnView,
1569
- plan: Pick<
1570
- TelegramOutboundReplyPlan,
1571
- "voiceText" | "voiceReplies" | "lang" | "rate" | "replyMarkup"
1572
- >,
1573
- options?: { replyToPrompt?: boolean },
1574
- ): Promise<void> {
1575
- // Normalize voice replies: either use explicit voiceReplies array or fall back to voiceText
1576
- const voiceReplies = plan.voiceReplies?.length
1577
- ? plan.voiceReplies
1578
- : plan.voiceText
1579
- ? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
1580
- : [];
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
- );
1603
- }
1604
- };
1605
- }
1606
-
1607
- export function createTelegramButtonPromptTurn(options: {
1608
- chatId: number;
1609
- replyToMessageId: number;
1610
- queueOrder: number;
1611
- action: TelegramOutboundButtonAction;
1612
- }): PendingTelegramTurn {
1613
- const prompt = `[telegram] ${options.action.prompt}`;
1614
- return {
1615
- kind: "prompt",
1616
- chatId: options.chatId,
1617
- replyToMessageId: options.replyToMessageId,
1618
- sourceMessageIds: [options.replyToMessageId],
1619
- queueOrder: options.queueOrder,
1620
- queueLane: "default",
1621
- laneOrder: options.queueOrder,
1622
- queuedAttachments: [],
1623
- content: [{ type: "text", text: prompt }],
1624
- historyText: options.action.prompt,
1625
- statusSummary: truncateTelegramQueueSummary(
1626
- options.action.text || options.action.prompt,
1627
- ),
1628
- };
1629
- }
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
- */
1636
- export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
1637
- query: TelegramButtonCallbackQuery,
1638
- ctx: TContext,
1639
- deps: TelegramButtonCallbackHandlerDeps<TContext>,
1640
- ): Promise<boolean> {
1641
- const action = deps.resolveAction(query.data);
1642
-
1643
- // Unknown / expired button (we only own tgbtn: keys)
1644
- if (!action) {
1645
- if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1646
- await deps.answerCallbackQuery(query.id, "Button action expired.");
1647
- return true;
1648
- }
1649
- return false;
1650
- }
1651
-
1652
- // Invalid message context (should not happen for private chat buttons)
1653
- const chatId = query.message?.chat?.id;
1654
- const messageId = query.message?.message_id;
1655
- if (typeof chatId !== "number" || typeof messageId !== "number") {
1656
- await deps.answerCallbackQuery(query.id, "Button action expired.");
1657
- return true;
1658
- }
1659
-
1660
- deps.enqueueButtonPrompt(query, action, ctx);
1661
- await deps.answerCallbackQuery(query.id, "Queued.");
1662
- return true;
1663
- }