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