@llblab/pi-telegram 0.12.0 → 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.
package/lib/outbound.ts CHANGED
@@ -1,64 +1,42 @@
1
1
  /**
2
2
  * Telegram outbound surface helpers
3
- * Zones: telegram outbound, assistant markup, command templates, callback routing
4
- * Owns assistant-authored outbound markup extraction, configured artifact generation, callback actions, runtime-event bridge, and Telegram outbound delivery
3
+ * Zones: telegram outbound, command templates, voice delivery
4
+ * Owns configured outbound handler execution, text transforms, voice-file generation/delivery, runtime-event bridge, and compatibility re-exports; assistant markup parsing lives in outbound-markup and button callback actions live in outbound-buttons
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
8
- import { mkdir, unlink } from "node:fs/promises";
8
+ import { mkdir } from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
- import { basename, extname, join, resolve } from "node:path";
10
+ import { join, resolve } from "node:path";
11
11
 
12
- import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
- import type { PendingTelegramTurn } from "./queue.ts";
14
-
15
- import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
12
+ import {
13
+ planTelegramButtonReply,
14
+ type TelegramButtonActionStore,
15
+ type TelegramOutboundButtonMarkup,
16
+ } from "./outbound-buttons.ts";
17
+ import {
18
+ planTelegramVoiceReply,
19
+ type TelegramVoiceReplyItem,
20
+ } from "./outbound-markup.ts";
21
+ import { createTelegramVoiceReplySender as createTelegramVoiceReplySenderWithPorts } from "./outbound-voice.ts";
16
22
 
17
23
  const OUTBOUND_HANDLER_REGISTRY_KEY = "__piTelegramOutboundHandlers__";
18
24
  const VOICE_EVENT_RECORDER_KEY = "__piTelegramVoiceEventRecorder__";
19
25
 
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
26
  import {
46
27
  buildCommandTemplateInvocation,
47
28
  expandCommandTemplateConfigs,
29
+ substituteCommandTemplateToken,
48
30
  type CommandTemplateObjectConfig,
49
31
  } 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
32
  const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
55
33
 
56
34
  // --- Types ---
57
35
 
58
36
  /**
59
37
  * 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
38
+ * Voice synthesis provider extensions can call this to surface diagnostics
39
+ * alongside pi-telegram's own events. Events are silently dropped
62
40
  * when pi-telegram is not loaded.
63
41
  */
64
42
  export type TelegramRuntimeEventRecorder = (
@@ -92,24 +70,19 @@ export type TelegramOutboundCommandTemplateConfig =
92
70
  export interface TelegramOutboundHandlerConfig extends CommandTemplateObjectConfig {
93
71
  type?: string;
94
72
  match?: string | string[];
95
- pipe?: TelegramOutboundCommandTemplateConfig[];
96
73
  output?: string;
97
- timeout?: number;
98
- }
99
-
100
- export interface TelegramVoiceReplyItem {
101
- text: string;
102
- lang?: string;
103
- rate?: string;
74
+ timeout?: number | string;
104
75
  }
105
76
 
106
- export interface TelegramVoiceReplyPlan {
107
- markdown: string;
108
- voiceText?: string;
109
- voiceReplies?: TelegramVoiceReplyItem[];
110
- lang?: string;
111
- rate?: string;
112
- }
77
+ export {
78
+ normalizeMarkdownAfterVoiceExtraction,
79
+ planTelegramVoiceReply,
80
+ stripTelegramCommentMarkupForDelivery,
81
+ stripTelegramCommentMarkupForPreview,
82
+ stripTelegramVoiceMarkupForPreview,
83
+ type TelegramVoiceReplyItem,
84
+ type TelegramVoiceReplyPlan,
85
+ } from "./outbound-markup.ts";
113
86
 
114
87
  export interface TelegramVoiceExecOptions {
115
88
  cwd?: string;
@@ -278,172 +251,30 @@ export interface TelegramOutboundTextPreviewRuntimeDeps<
278
251
  recordRuntimeEvent?: TelegramVoiceReplySenderDeps["recordRuntimeEvent"];
279
252
  }
280
253
 
281
- interface TelegramTopLevelHtmlComment {
282
- raw: string;
283
- content: string;
284
- start: number;
285
- end: number;
286
- }
287
-
288
- interface TelegramTopLevelFenceState {
289
- marker: "`" | "~";
290
- length: number;
291
- }
292
-
293
- function isTelegramActionCommentContent(content: string): boolean {
294
- const normalizedContent = content.replace(/^\s+/, "");
295
- const [head = ""] = normalizedContent.split(/\r?\n/, 1);
296
- return ["telegram_voice", "telegram_button"].some((command) => {
297
- if (!head.startsWith(command)) return false;
298
- const nextChar = head[command.length];
299
- return nextChar === undefined || /\s|:/.test(nextChar);
300
- });
301
- }
302
-
303
- function getMarkdownLineEnd(markdown: string, offset: number): number {
304
- const newlineIndex = markdown.indexOf("\n", offset);
305
- return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
306
- }
307
-
308
- function getMarkdownLineText(
309
- markdown: string,
310
- offset: number,
311
- end: number,
312
- ): string {
313
- return markdown.slice(offset, end).replace(/\r?\n$/, "");
314
- }
315
-
316
- function getTopLevelOpeningFence(
317
- line: string,
318
- ): TelegramTopLevelFenceState | undefined {
319
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
320
- const sequence = match?.[1];
321
- if (!sequence) return undefined;
322
- return {
323
- marker: sequence[0] as "`" | "~",
324
- length: sequence.length,
325
- };
326
- }
327
-
328
- function isTopLevelClosingFence(
329
- line: string,
330
- fence: TelegramTopLevelFenceState,
331
- ): boolean {
332
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
333
- const sequence = match?.[1];
334
- return (
335
- !!sequence &&
336
- sequence[0] === fence.marker &&
337
- sequence.length >= fence.length
338
- );
339
- }
340
-
341
- function collectInlineClosedTelegramActionBody(
342
- markdown: string,
343
- bodyStart: number,
344
- commentContent: string,
345
- ): { content: string; end: number } | undefined {
346
- const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
347
- const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
348
- const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
349
- const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
350
- const hasRecoverableBody =
351
- isTelegramActionCommentContent(commentContent) &&
352
- bodyLine.trim() !== "" &&
353
- !bodyLine.startsWith("<!--") &&
354
- !bodyLine.startsWith("-->") &&
355
- closeLine === "-->";
356
- if (!hasRecoverableBody) return undefined;
357
- return {
358
- content: `${commentContent.trimEnd()}\n${bodyLine}`,
359
- end: bodyLineEnd + 3,
360
- };
361
- }
362
-
363
- function collectTopLevelHtmlComments(markdown: string): {
364
- comments: TelegramTopLevelHtmlComment[];
365
- openCommentStart?: number;
366
- } {
367
- const comments: TelegramTopLevelHtmlComment[] = [];
368
- let offset = 0;
369
- let fence: TelegramTopLevelFenceState | undefined;
370
- while (offset < markdown.length) {
371
- const lineEnd = getMarkdownLineEnd(markdown, offset);
372
- const line = getMarkdownLineText(markdown, offset, lineEnd);
373
- if (fence) {
374
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
375
- offset = lineEnd;
376
- continue;
377
- }
378
- const nextFence = getTopLevelOpeningFence(line);
379
- if (nextFence) {
380
- fence = nextFence;
381
- offset = lineEnd;
382
- continue;
383
- }
384
- if (line.startsWith("<!--")) {
385
- const closeIndex = markdown.indexOf("-->", offset + 4);
386
- if (closeIndex === -1) return { comments, openCommentStart: offset };
387
- let end = closeIndex + 3;
388
- let raw = markdown.slice(offset, end);
389
- let content = raw.slice(4, -3);
390
- const closeColumn = closeIndex - offset;
391
- const closesOnOpeningLine = closeIndex < lineEnd;
392
- const hasOnlyWhitespaceAfterClose =
393
- line.slice(closeColumn + 3).trim() === "";
394
- const inlineBody =
395
- closesOnOpeningLine && hasOnlyWhitespaceAfterClose
396
- ? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
397
- : undefined;
398
- if (inlineBody) {
399
- end = inlineBody.end;
400
- raw = markdown.slice(offset, end);
401
- content = inlineBody.content;
402
- }
403
- comments.push({ raw, content, start: offset, end });
404
- offset = getMarkdownLineEnd(markdown, end);
405
- continue;
406
- }
407
- offset = lineEnd;
408
- }
409
- return { comments };
410
- }
411
-
412
- // --- Voice Delivery Helpers ---
413
-
414
- function extractVoiceResult(result: any): {
415
- filePath: string;
416
- transcriptText?: string;
417
- } {
418
- if (typeof result === "string") {
419
- return { filePath: result };
420
- }
421
- return {
422
- filePath: result.audioPath,
423
- transcriptText: result.transcriptText,
424
- };
425
- }
254
+ // --- Voice Reply Timeout Helpers ---
426
255
 
427
- async function sendVoiceChatAction(
428
- deps: TelegramVoiceReplySenderDeps,
429
- chatId: number,
430
- ) {
431
- if (deps.sendRecordVoiceAction) {
432
- await deps.sendRecordVoiceAction(chatId).catch(() => {});
433
- } else {
434
- await deps.sendChatAction?.(chatId, "record_voice").catch(() => {});
435
- }
256
+ function resolveOutboundNumericControlField(
257
+ value: number | string | undefined,
258
+ values: Record<string, unknown>,
259
+ label: string,
260
+ ): number | undefined {
261
+ if (value === undefined) return undefined;
262
+ const resolved =
263
+ typeof value === "string"
264
+ ? substituteCommandTemplateToken(value, values, label)
265
+ : value;
266
+ if (resolved === "") return undefined;
267
+ const numeric = Number(resolved);
268
+ if (!Number.isFinite(numeric) || numeric < 0)
269
+ throw new Error(`Command template ${label} must be a non-negative number.`);
270
+ return numeric;
436
271
  }
437
272
 
438
- // --- Voice Reply Timeout Helpers ---
439
-
440
273
  function getVoiceReplyConfiguredTimeout(
441
274
  config: TelegramOutboundCommandTemplateConfig | undefined,
442
275
  ): number | undefined {
443
276
  const timeout = typeof config === "string" ? undefined : config?.timeout;
444
- return typeof timeout === "number" && Number.isFinite(timeout) && timeout > 0
445
- ? timeout
446
- : undefined;
277
+ return resolveOutboundNumericControlField(timeout, {}, "timeout");
447
278
  }
448
279
 
449
280
  function getVoiceReplyTimeout(
@@ -513,7 +344,13 @@ async function runVoiceReplyCommand(
513
344
  cwd: options.cwd,
514
345
  timeout: options.timeout,
515
346
  ...(typeof config === "object" && config.retry !== undefined
516
- ? { retry: config.retry }
347
+ ? {
348
+ retry: resolveOutboundNumericControlField(
349
+ config.retry,
350
+ {},
351
+ "retry",
352
+ ),
353
+ }
517
354
  : {}),
518
355
  ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
519
356
  },
@@ -568,12 +405,6 @@ function getTelegramVoiceHandlerCompositionSteps(
568
405
  handler,
569
406
  ) as TelegramOutboundCommandTemplateConfig[];
570
407
  }
571
- if (handler.pipe?.length) {
572
- return expandCommandTemplateConfigs({
573
- ...handler,
574
- template: handler.pipe,
575
- }) as TelegramOutboundCommandTemplateConfig[];
576
- }
577
408
  return [];
578
409
  }
579
410
 
@@ -664,7 +495,7 @@ async function generateTelegramVoiceReplyFileWithHandler(
664
495
  );
665
496
  stdout = result.stdout;
666
497
  } catch (error) {
667
- if (typeof step === "object" && step.critical) throw error;
498
+ if (typeof step === "object" && step.failure === "root") throw error;
668
499
  stdout = "";
669
500
  }
670
501
  }
@@ -696,7 +527,7 @@ export async function generateTelegramVoiceReplyFile(
696
527
  },
697
528
  ): Promise<string | undefined> {
698
529
  const handler = options.handler;
699
- if (!handler?.template && !handler?.pipe?.length) return undefined;
530
+ if (!handler?.template) return undefined;
700
531
  return generateTelegramVoiceReplyFileWithHandler(text, {
701
532
  lang: options.lang,
702
533
  rate: options.rate,
@@ -744,7 +575,7 @@ async function transformTelegramOutboundTextWithHandler(
744
575
  );
745
576
  stdout = result.stdout;
746
577
  } catch (error) {
747
- if (typeof step === "object" && step.critical) throw error;
578
+ if (typeof step === "object" && step.failure === "root") throw error;
748
579
  stdout = "";
749
580
  }
750
581
  if (!stdout) stdout = text;
@@ -939,8 +770,8 @@ export {
939
770
  clearTelegramVoiceTranscriptionProviders,
940
771
  computeVoicePromptContribution,
941
772
  computeVoiceTurnFlags,
942
- getTelegramVoiceSynthesisProviders,
943
773
  getTelegramVoiceReplyMode,
774
+ getTelegramVoiceSynthesisProviders,
944
775
  getTelegramVoiceTranscriptionProviders,
945
776
  hasTelegramVoiceSynthesisProvider,
946
777
  hasTelegramVoiceTranscriptionProvider,
@@ -948,590 +779,52 @@ export {
948
779
  registerTelegramVoiceSynthesisProvider,
949
780
  registerTelegramVoiceTranscriptionProvider,
950
781
  shouldSuppressPreviewForVoice,
782
+ type TelegramVoiceReplyMode,
951
783
  type TelegramVoiceSynthesisProvider,
952
784
  type TelegramVoiceSynthesisProviderResult,
953
- type TelegramVoiceReplyMode,
954
785
  type TelegramVoiceTranscriptionFile,
955
786
  type TelegramVoiceTranscriptionProvider,
956
787
  type TelegramVoiceTranscriptionProviderResult,
957
788
  type TelegramVoiceTurnView,
958
789
  } from "./voice.ts";
959
790
 
960
- // --- Voice Delivery ---
961
-
962
- /**
963
- * Creates a function that sends voice replies using registered voice synthesis providers.
964
- *
965
- * This is the main entry point for delivering voice messages.
966
- * The actual decision logic (when to use voice) lives in `lib/voice.ts`.
967
- */
968
791
  export function createTelegramVoiceReplySender(
969
792
  deps: TelegramVoiceReplySenderDeps,
970
793
  ) {
971
- async function uploadVoiceFile(
972
- turn: TelegramVoiceReplyTurnView,
973
- filePath: string,
974
- options?: {
975
- replyToPrompt?: boolean;
976
- replyMarkup?: unknown;
977
- transcriptText?: string;
978
- },
979
- ): Promise<void> {
980
- const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
981
- await sendVoiceChatAction(deps, turn.chatId);
982
- const replyParameters = buildVoiceReplyParameters(
983
- options?.replyToPrompt,
984
- turn.replyToMessageId,
985
- );
986
- await deps.sendMultipart(
987
- "sendVoice",
988
- {
989
- chat_id: String(turn.chatId),
990
- ...(options?.transcriptText ? { caption: options.transcriptText } : {}),
991
- ...(replyParameters ? { reply_parameters: replyParameters } : {}),
992
- ...(options?.replyMarkup !== undefined && options.replyMarkup !== null
993
- ? {
994
- reply_markup:
995
- typeof options.replyMarkup === "string"
996
- ? options.replyMarkup
997
- : JSON.stringify(options.replyMarkup),
998
- }
999
- : {}),
1000
- },
1001
- "voice",
1002
- voiceFilePath,
1003
- basename(voiceFilePath),
1004
- );
1005
- }
1006
-
1007
- return async function sendVoiceReply(
1008
- turn: TelegramVoiceReplyTurnView,
1009
- text: string,
1010
- options?: {
1011
- lang?: string;
1012
- rate?: string;
1013
- replyToPrompt?: boolean;
1014
- replyMarkup?: unknown;
1015
- },
1016
- ): Promise<void> {
1017
- for (const handler of findTelegramOutboundHandlers(
1018
- deps.getHandlers?.(),
1019
- "voice",
1020
- )) {
1021
- try {
1022
- const filePath = await generateTelegramVoiceReplyFile(text, {
1023
- lang: options?.lang,
1024
- rate: options?.rate,
1025
- handler,
1026
- tempDir: deps.tempDir,
1027
- cwd: deps.cwd,
1028
- execCommand: deps.execCommand,
1029
- });
1030
- if (!filePath) continue;
1031
- await uploadVoiceFile(turn, filePath, {
1032
- replyToPrompt: options?.replyToPrompt,
1033
- replyMarkup: options?.replyMarkup,
1034
- });
1035
- return;
1036
- } catch (error) {
1037
- deps.recordRuntimeEvent?.("voice", error, {
1038
- phase: "template-handler-send",
1039
- });
1040
- }
1041
- }
1042
-
1043
- for (const handler of getTelegramOutboundProgrammaticHandlers("voice")) {
1044
- try {
1045
- const filePath = await handler(text, {
1046
- lang: options?.lang,
1047
- rate: options?.rate,
1048
- });
1049
- if (!filePath) continue;
1050
- await uploadVoiceFile(turn, filePath, {
1051
- replyToPrompt: options?.replyToPrompt,
1052
- replyMarkup: options?.replyMarkup,
1053
- });
1054
- return;
1055
- } catch (error) {
1056
- deps.recordRuntimeEvent?.("voice", error, {
1057
- phase: "programmatic-handler-send",
1058
- });
1059
- }
1060
- }
1061
-
1062
- const providers = getTelegramVoiceSynthesisProviders();
1063
-
1064
- for (const provider of providers) {
1065
- let voiceFilePath: string | undefined;
1066
- let originalFilePath: string | undefined;
1067
-
1068
- try {
1069
- if (typeof provider !== "function") {
1070
- deps.recordRuntimeEvent?.(
1071
- "voice",
1072
- new Error(
1073
- "Registered voice synthesis provider is not callable (policy-only object?)",
1074
- ),
1075
- { phase: "voice-provider-skip" },
1076
- );
1077
- continue;
1078
- }
1079
-
1080
- const providerResult = await provider(text, {
1081
- lang: options?.lang,
1082
- rate: options?.rate,
1083
- });
1084
-
1085
- if (!providerResult) {
1086
- deps.recordRuntimeEvent?.(
1087
- "voice",
1088
- new Error("Voice synthesis provider returned empty path"),
1089
- { phase: "voice-provider-skip" },
1090
- );
1091
- continue;
1092
- }
1093
-
1094
- const { filePath, transcriptText } = extractVoiceResult(providerResult);
1095
- voiceFilePath = filePath;
1096
- originalFilePath = filePath;
1097
- await uploadVoiceFile(turn, filePath, {
1098
- replyToPrompt: options?.replyToPrompt,
1099
- replyMarkup: options?.replyMarkup,
1100
- transcriptText,
1101
- });
1102
- return;
1103
- } catch (error) {
1104
- deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
1105
- } finally {
1106
- if (voiceFilePath && voiceFilePath !== originalFilePath) {
1107
- await unlink(voiceFilePath).catch(() => {});
1108
- }
1109
- }
1110
- }
1111
-
1112
- const errorMessage =
1113
- "Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.";
1114
- deps.recordRuntimeEvent?.("voice", new Error(errorMessage), {
1115
- phase: "send",
1116
- });
1117
- throw new Error(errorMessage);
1118
- };
1119
- }
1120
-
1121
- export interface TelegramOutboundButtonAction {
1122
- text: string;
1123
- prompt: string;
1124
- }
1125
-
1126
- export interface TelegramOutboundButtonStoredAction extends TelegramOutboundButtonAction {
1127
- createdAt: number;
1128
- }
1129
-
1130
- export type TelegramOutboundButtonMarkup = TelegramInlineKeyboardMarkup;
1131
-
1132
- export interface TelegramButtonReplyPlan {
1133
- markdown: string;
1134
- replyMarkup?: TelegramOutboundButtonMarkup;
1135
- }
1136
-
1137
- export interface TelegramButtonActionStore {
1138
- register: (action: TelegramOutboundButtonAction) => string;
1139
- resolve: (
1140
- callbackData: string | undefined,
1141
- ) => TelegramOutboundButtonAction | undefined;
1142
- }
1143
-
1144
- export interface TelegramButtonCallbackQuery {
1145
- id: string;
1146
- data?: string;
1147
- message?: {
1148
- message_id?: number;
1149
- chat?: { id?: number };
1150
- };
1151
- }
1152
-
1153
- export interface TelegramButtonCallbackHandlerDeps<TContext = unknown> {
1154
- resolveAction: (
1155
- callbackData: string | undefined,
1156
- ) => TelegramOutboundButtonAction | undefined;
1157
- answerCallbackQuery: (
1158
- callbackQueryId: string,
1159
- text?: string,
1160
- ) => Promise<void>;
1161
- enqueueButtonPrompt: (
1162
- query: TelegramButtonCallbackQuery,
1163
- action: TelegramOutboundButtonAction,
1164
- ctx: TContext,
1165
- ) => void;
1166
- }
1167
-
1168
- function nowMs(): number {
1169
- return Date.now();
1170
- }
1171
-
1172
- function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
1173
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
1174
- }
1175
-
1176
- export function replaceTopLevelHtmlComments(
1177
- markdown: string,
1178
- replacer: (comment: TelegramTopLevelHtmlComment) => string,
1179
- ): string {
1180
- const { comments } = collectTopLevelHtmlComments(markdown);
1181
- if (comments.length === 0) return markdown;
1182
- let result = "";
1183
- let offset = 0;
1184
- for (const comment of comments) {
1185
- result += markdown.slice(offset, comment.start);
1186
- result += replacer(comment);
1187
- offset = comment.end;
1188
- }
1189
- return result + markdown.slice(offset);
1190
- }
1191
-
1192
- export function findTopLevelOpenOrPartialHtmlCommentIndex(
1193
- markdown: string,
1194
- ): number {
1195
- const { openCommentStart } = collectTopLevelHtmlComments(markdown);
1196
- if (openCommentStart !== undefined) return openCommentStart;
1197
- let offset = 0;
1198
- let fence: TelegramTopLevelFenceState | undefined;
1199
- while (offset < markdown.length) {
1200
- const lineEnd = getMarkdownLineEnd(markdown, offset);
1201
- const line = getMarkdownLineText(markdown, offset, lineEnd);
1202
- const isLastLine = lineEnd >= markdown.length;
1203
- if (fence) {
1204
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
1205
- offset = lineEnd;
1206
- continue;
1207
- }
1208
- const nextFence = getTopLevelOpeningFence(line);
1209
- if (nextFence) {
1210
- fence = nextFence;
1211
- offset = lineEnd;
1212
- continue;
1213
- }
1214
- if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
1215
- return offset;
1216
- }
1217
- offset = lineEnd;
1218
- }
1219
- return -1;
1220
- }
1221
-
1222
- export function parseTopLevelTelegramComment(
1223
- comment: TelegramTopLevelHtmlComment,
1224
- command: string,
1225
- ): { head: string; body?: string } | undefined {
1226
- let normalizedContent = comment.content.replace(/^\s+/, "");
1227
- // Support both <!-- telegram_voice ... --> and <!--!telegram_voice ... --> forms
1228
- normalizedContent = normalizedContent.replace(/^!/, "");
1229
- const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
1230
- let head = rawHead.trimStart();
1231
- // Only tolerate the '!' prefix (used in <!--!telegram_voice ... --> form).
1232
- // We intentionally do *not* do a broad strip of arbitrary non-letter characters
1233
- // to preserve the "column-zero only" + "must start with telegram_voice" contract.
1234
- if (!head.startsWith(command)) return undefined;
1235
- const nextChar = head[command.length];
1236
- if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
1237
- return {
1238
- head: head.slice(command.length),
1239
- ...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
1240
- };
1241
- }
1242
-
1243
- // --- Voice Comment Parsing Helpers ---
1244
-
1245
- /**
1246
- * Extracts label and prompt from a telegram_button comment string.
1247
- */
1248
- export function parseTelegramCommentAttributes(
1249
- input: string,
1250
- ): Record<string, string> {
1251
- const attributes: Record<string, string> = {};
1252
- for (const match of input.matchAll(
1253
- /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
1254
- )) {
1255
- const key = match[1];
1256
- const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
1257
- if (value) attributes[key] = value;
1258
- }
1259
- return attributes;
1260
- }
1261
-
1262
- function parseButtonsCommentAttributes(input: string): {
1263
- label?: string;
1264
- prompt?: string;
1265
- } {
1266
- const attributes = parseTelegramCommentAttributes(input);
1267
- return {
1268
- ...(attributes.label ? { label: attributes.label } : {}),
1269
- ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
1270
- };
1271
- }
1272
-
1273
- /**
1274
- * Parses the content of a telegram_button comment into button rows.
1275
- * Supports simple forms and forms with explicit label + prompt.
1276
- */
1277
- function parseButtonsCommentRows(
1278
- head: string,
1279
- body: string | undefined,
1280
- ): TelegramOutboundButtonAction[][] {
1281
- const trimmedHead = head.trim();
1282
-
1283
- if (body === undefined) {
1284
- if (trimmedHead.startsWith(":")) {
1285
- const label = trimmedHead.slice(1).trim();
1286
- return label ? [[{ text: label, prompt: label }]] : [];
1287
- }
1288
- const attributes = parseButtonsCommentAttributes(head);
1289
- return attributes.label && attributes.prompt
1290
- ? [[{ text: attributes.label, prompt: attributes.prompt }]]
1291
- : [];
1292
- }
1293
-
1294
- const label = parseButtonsCommentAttributes(head).label;
1295
- const prompt = body.trim();
1296
- if (!label || !prompt) return [];
1297
- return [[{ text: label, prompt }]];
1298
- }
1299
-
1300
- // --- Voice Reply Planning ---
1301
-
1302
- // The generic comment parsing helpers (replaceTopLevelHtmlComments, etc.)
1303
- // live locally in this file (used by both Voice and Button parsing).
1304
-
1305
- export function normalizeMarkdownAfterVoiceExtraction(
1306
- markdown: string,
1307
- ): string {
1308
- return markdown.replace(/\n{3,}/g, "\n\n").trim();
1309
- }
1310
-
1311
- function parseVoiceReplyAttributes(input: string): {
1312
- lang?: string;
1313
- rate?: string;
1314
- text?: string;
1315
- } {
1316
- const attributes = parseTelegramCommentAttributes(input);
1317
- return {
1318
- ...(attributes.lang ? { lang: attributes.lang } : {}),
1319
- ...(attributes.rate ? { rate: attributes.rate } : {}),
1320
- ...(attributes.text ? { text: attributes.text } : {}),
1321
- };
1322
- }
1323
-
1324
- function parseVoiceCommentBody(
1325
- head: string,
1326
- body: string | undefined,
1327
- ): {
1328
- attrs: string;
1329
- text: string;
1330
- } {
1331
- const trimmedHead = head.trim();
1332
- if (body !== undefined) {
1333
- return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
1334
- }
1335
- // Always look for the first colon (that is not inside quotes) to separate attributes from text.
1336
- // This handles both simple ": text" and "attributes: text" forms.
1337
- let colonIndex = -1;
1338
- let inQuote = false;
1339
- let quoteChar = "";
1340
- for (let i = 0; i < trimmedHead.length; i++) {
1341
- const char = trimmedHead[i];
1342
- if (inQuote) {
1343
- if (char === quoteChar) inQuote = false;
1344
- } else {
1345
- if (char === '"' || char === "'") {
1346
- inQuote = true;
1347
- quoteChar = char;
1348
- } else if (char === ":") {
1349
- colonIndex = i;
1350
- break;
1351
- }
1352
- }
1353
- }
1354
- if (colonIndex > 0) {
1355
- const attrsPart = trimmedHead.slice(0, colonIndex).trim();
1356
- const textPart = trimmedHead.slice(colonIndex + 1).trim();
1357
- const attrs = parseVoiceReplyAttributes(attrsPart);
1358
- return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
1359
- }
1360
- if (trimmedHead.startsWith(":")) {
1361
- return { attrs: "", text: trimmedHead.slice(1).trim() };
1362
- }
1363
- const attrs = parseVoiceReplyAttributes(trimmedHead);
1364
- return { attrs: trimmedHead, text: attrs.text ?? "" };
1365
- }
1366
-
1367
- export function stripTelegramCommentMarkupForPreview(markdown: string): string {
1368
- const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
1369
- const openBlockIndex =
1370
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
1371
- const previewMarkdown =
1372
- openBlockIndex >= 0
1373
- ? withoutClosedBlocks.slice(0, openBlockIndex)
1374
- : withoutClosedBlocks;
1375
- return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
1376
- }
1377
-
1378
- export function stripTelegramCommentMarkupForDelivery(
1379
- markdown: string,
1380
- ): string {
1381
- const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
1382
- const openBlockIndex =
1383
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
1384
- const deliveryMarkdown =
1385
- openBlockIndex >= 0
1386
- ? withoutClosedBlocks.slice(0, openBlockIndex)
1387
- : withoutClosedBlocks;
1388
- return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
1389
- }
1390
-
1391
- export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
1392
- return stripTelegramCommentMarkupForPreview(markdown);
1393
- }
1394
-
1395
- /**
1396
- * Parse a Markdown reply for `telegram_voice` blocks and build a voice reply plan.
1397
- */
1398
- export function planTelegramVoiceReply(
1399
- markdown: string,
1400
- ): TelegramVoiceReplyPlan {
1401
- const voiceReplies: TelegramVoiceReplyItem[] = [];
1402
- let lang: string | undefined;
1403
- let rate: string | undefined;
1404
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
1405
- let command = parseTopLevelTelegramComment(comment, "telegram_voice");
1406
- if (!command) {
1407
- // Robust fallback for Voice-specific comments.
1408
- // Reached only for certain edge-case extractions from collectTopLevelHtmlComments
1409
- // (e.g. comments with unusual leading characters or legacy forms that survive the
1410
- // normalization in parseTopLevelTelegramComment but still contain "telegram_voice").
1411
- // This path is intentionally narrow and not exercised by current documented usage.
1412
- let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
1413
- if (content.startsWith("telegram_voice")) {
1414
- const headPart = content.slice("telegram_voice".length).trim();
1415
- command = { head: headPart, body: undefined };
1416
- }
1417
- }
1418
- if (!command) return "";
1419
- const parsed = parseVoiceCommentBody(command.head, command.body);
1420
- const attrs = parseVoiceReplyAttributes(parsed.attrs);
1421
- if (parsed.text) {
1422
- voiceReplies.push({
1423
- text: parsed.text,
1424
- ...(attrs.lang ? { lang: attrs.lang } : {}),
1425
- ...(attrs.rate ? { rate: attrs.rate } : {}),
1426
- });
1427
- }
1428
- if (attrs.lang) lang = attrs.lang;
1429
- if (attrs.rate) rate = attrs.rate;
1430
- return "";
1431
- });
1432
- const voiceText = voiceReplies
1433
- .map((reply) => reply.text)
1434
- .join("\n\n")
1435
- .trim();
1436
- return {
1437
- markdown: stripTelegramCommentMarkupForDelivery(stripped),
1438
- ...(voiceText ? { voiceText } : {}),
1439
- ...(voiceReplies.length > 0 ? { voiceReplies } : {}),
1440
- ...(lang ? { lang } : {}),
1441
- ...(rate ? { rate } : {}),
1442
- };
1443
- }
1444
-
1445
- // --- Button And Action Handling ---
1446
-
1447
- /**
1448
- * Handles assistant-authored buttons (<!-- telegram_button -->) and their callbacks.
1449
- * Supports both simple buttons and buttons that enqueue a prompt when clicked.
1450
- */
1451
-
1452
- /**
1453
- * Creates an in-memory store for button actions.
1454
- * Buttons can be registered with a prompt that gets enqueued when the button is clicked.
1455
- * Old actions are automatically cleaned up after the configured TTL.
1456
- */
1457
- export function createTelegramButtonActionStore(
1458
- options: { ttlMs?: number } = {},
1459
- ): TelegramButtonActionStore {
1460
- const ttlMs = options.ttlMs ?? TELEGRAM_BUTTON_ACTION_TTL_MS;
1461
- const actions = new Map<string, TelegramOutboundButtonStoredAction>();
1462
- function cleanup(currentTime: number): void {
1463
- for (const [key, action] of actions) {
1464
- if (currentTime - action.createdAt > ttlMs) actions.delete(key);
1465
- }
1466
- }
1467
- return {
1468
- register: (action) => {
1469
- const currentTime = nowMs();
1470
- cleanup(currentTime);
1471
-
1472
- // Short random key for the callback_data (e.g. tgbtn:abcd1234)
1473
- const key = `${TELEGRAM_BUTTON_CALLBACK_PREFIX}:${randomUUID().slice(0, 8)}`;
1474
- actions.set(key, { ...action, createdAt: currentTime });
1475
- return key;
1476
- },
1477
- resolve: (callbackData) => {
1478
- if (!callbackData?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1479
- return undefined;
1480
- }
1481
-
1482
- const currentTime = nowMs();
1483
- cleanup(currentTime);
1484
-
1485
- const action = actions.get(callbackData);
1486
- if (!action) return undefined;
1487
-
1488
- return { text: action.text, prompt: action.prompt };
1489
- },
1490
- };
1491
- }
1492
-
1493
- /**
1494
- * Parses assistant markdown for `<!-- telegram_button -->` blocks
1495
- * and builds a button plan (inline keyboard + registered actions).
1496
- * Supports both simple label-only buttons and buttons with explicit prompts.
1497
- */
1498
- export function planTelegramButtonReply(
1499
- markdown: string,
1500
- deps: { registerAction: (action: TelegramOutboundButtonAction) => string },
1501
- ): TelegramButtonReplyPlan {
1502
- const keyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
1503
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
1504
- const command = parseTopLevelTelegramComment(comment, "telegram_button");
1505
- if (!command) return comment.raw;
1506
- const rows = parseButtonsCommentRows(command.head, command.body);
1507
- for (const row of rows) {
1508
- keyboard.push(
1509
- row.map((button) => ({
1510
- text: button.text,
1511
- callback_data: deps.registerAction(button),
1512
- })),
1513
- );
1514
- }
1515
- return "";
794
+ return createTelegramVoiceReplySenderWithPorts(deps, {
795
+ findVoiceHandlers: (handlers) =>
796
+ findTelegramOutboundHandlers(
797
+ handlers as TelegramOutboundHandlerConfig[] | undefined,
798
+ "voice",
799
+ ),
800
+ generateVoiceFile: (text, options) =>
801
+ generateTelegramVoiceReplyFile(text, {
802
+ lang: options.lang,
803
+ rate: options.rate,
804
+ handler: options.handler,
805
+ tempDir: options.tempDir,
806
+ cwd: options.cwd,
807
+ execCommand: options.execCommand,
808
+ }),
809
+ getProgrammaticVoiceHandlers: () =>
810
+ getTelegramOutboundProgrammaticHandlers("voice"),
1516
811
  });
1517
- return {
1518
- markdown: normalizeMarkdownAfterButtonExtraction(stripped),
1519
- ...(keyboard.length > 0
1520
- ? { replyMarkup: { inline_keyboard: keyboard } }
1521
- : {}),
1522
- };
1523
812
  }
1524
813
 
1525
- /**
1526
- * Creates a thin planner that combines `planTelegramButtonReply` with a given action store.
1527
- * Mainly used to keep the call site clean when planning button replies from the artifact sender.
1528
- */
1529
- export function createTelegramButtonReplyPlanner(
1530
- store: Pick<TelegramButtonActionStore, "register">,
1531
- ): (markdown: string) => TelegramButtonReplyPlan {
1532
- return (markdown) =>
1533
- planTelegramButtonReply(markdown, { registerAction: store.register });
1534
- }
814
+ export {
815
+ createTelegramButtonActionStore,
816
+ createTelegramButtonPromptTurn,
817
+ createTelegramButtonReplyPlanner,
818
+ handleTelegramButtonCallbackQuery,
819
+ planTelegramButtonReply,
820
+ type TelegramButtonActionStore,
821
+ type TelegramButtonCallbackHandlerDeps,
822
+ type TelegramButtonCallbackQuery,
823
+ type TelegramButtonReplyPlan,
824
+ type TelegramOutboundButtonAction,
825
+ type TelegramOutboundButtonMarkup,
826
+ type TelegramOutboundButtonStoredAction,
827
+ } from "./outbound-buttons.ts";
1535
828
 
1536
829
  export function createTelegramOutboundReplyPlanner(
1537
830
  store: Pick<TelegramButtonActionStore, "register">,
@@ -1613,61 +906,3 @@ export function createTelegramOutboundReplyArtifactSender(
1613
906
  }
1614
907
  };
1615
908
  }
1616
-
1617
- export function createTelegramButtonPromptTurn(options: {
1618
- chatId: number;
1619
- replyToMessageId: number;
1620
- queueOrder: number;
1621
- action: TelegramOutboundButtonAction;
1622
- }): PendingTelegramTurn {
1623
- const prompt = `[telegram] ${options.action.prompt}`;
1624
- return {
1625
- kind: "prompt",
1626
- chatId: options.chatId,
1627
- replyToMessageId: options.replyToMessageId,
1628
- sourceMessageIds: [options.replyToMessageId],
1629
- queueOrder: options.queueOrder,
1630
- queueLane: "default",
1631
- laneOrder: options.queueOrder,
1632
- queuedAttachments: [],
1633
- content: [{ type: "text", text: prompt }],
1634
- historyText: options.action.prompt,
1635
- statusSummary: truncateTelegramQueueSummary(
1636
- options.action.text || options.action.prompt,
1637
- ),
1638
- };
1639
- }
1640
-
1641
- /**
1642
- * Handles a button callback query.
1643
- * Resolves the stored action, answers the callback, and enqueues the associated prompt if present.
1644
- * Returns true if the query was handled by this system (even if the action had expired).
1645
- */
1646
- export async function handleTelegramButtonCallbackQuery<TContext = unknown>(
1647
- query: TelegramButtonCallbackQuery,
1648
- ctx: TContext,
1649
- deps: TelegramButtonCallbackHandlerDeps<TContext>,
1650
- ): Promise<boolean> {
1651
- const action = deps.resolveAction(query.data);
1652
-
1653
- // Unknown / expired button (we only own tgbtn: keys)
1654
- if (!action) {
1655
- if (query.data?.startsWith(`${TELEGRAM_BUTTON_CALLBACK_PREFIX}:`)) {
1656
- await deps.answerCallbackQuery(query.id, "Button action expired.");
1657
- return true;
1658
- }
1659
- return false;
1660
- }
1661
-
1662
- // Invalid message context (should not happen for private chat buttons)
1663
- const chatId = query.message?.chat?.id;
1664
- const messageId = query.message?.message_id;
1665
- if (typeof chatId !== "number" || typeof messageId !== "number") {
1666
- await deps.answerCallbackQuery(query.id, "Button action expired.");
1667
- return true;
1668
- }
1669
-
1670
- deps.enqueueButtonPrompt(query, action, ctx);
1671
- await deps.answerCallbackQuery(query.id, "Queued.");
1672
- return true;
1673
- }