@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/AGENTS.md +6 -5
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +25 -3
- package/README.md +3 -33
- package/docs/README.md +1 -1
- package/docs/architecture.md +2 -1
- package/docs/command-templates.md +18 -16
- package/docs/inbound.md +2 -2
- package/docs/outbound.md +1 -1
- package/docs/public-api.md +158 -4
- package/docs/sections.md +5 -5
- package/docs/voice.md +13 -8
- package/index.ts +4 -4
- package/lib/bindings.ts +2 -4
- package/lib/command-templates.ts +249 -60
- package/lib/config.ts +1 -2
- package/lib/inbound.ts +26 -17
- package/lib/locks.ts +4 -1
- package/lib/outbound-buttons.ts +226 -0
- package/lib/outbound-markup.ts +357 -0
- package/lib/outbound-voice.ts +263 -0
- package/lib/outbound.ts +87 -852
- package/lib/preview.ts +1 -1
- package/lib/queue.ts +3 -0
- package/lib/replies.ts +4 -1
- package/lib/status.ts +13 -0
- package/lib/{api.ts → telegram-api.ts} +4 -4
- package/lib/text-groups.ts +3 -2
- package/lib/voice.ts +35 -8
- package/package.json +12 -12
package/lib/outbound.ts
CHANGED
|
@@ -1,64 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Telegram outbound surface helpers
|
|
3
|
-
* Zones: telegram outbound,
|
|
4
|
-
* Owns
|
|
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
|
|
8
|
+
import { mkdir } from "node:fs/promises";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
|
-
import {
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
11
|
|
|
12
|
-
import
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
61
|
-
*
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
? {
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
"
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
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
|
-
}
|