@llblab/pi-telegram 0.12.0 → 0.13.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 +7 -6
- package/BACKLOG.md +26 -1
- package/CHANGELOG.md +31 -1
- 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 +3 -4
- package/lib/command-templates.ts +249 -60
- package/lib/config.ts +1 -2
- package/lib/inbound.ts +26 -17
- package/lib/lifecycle.ts +25 -8
- 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 +2 -1
- package/lib/queue.ts +3 -0
- package/lib/rendering.ts +20 -1
- package/lib/replies.ts +4 -1
- package/lib/routing.ts +2 -2
- 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
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram outbound markup parsing helpers
|
|
3
|
+
* Zones: telegram outbound, assistant markup
|
|
4
|
+
* Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface TelegramTopLevelHtmlComment {
|
|
8
|
+
raw: string;
|
|
9
|
+
content: string;
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface TelegramTopLevelFenceState {
|
|
15
|
+
marker: "`" | "~";
|
|
16
|
+
length: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isTelegramActionCommentContent(content: string): boolean {
|
|
20
|
+
const normalizedContent = content.replace(/^\s+/, "");
|
|
21
|
+
const [head = ""] = normalizedContent.split(/\r?\n/, 1);
|
|
22
|
+
return ["telegram_voice", "telegram_button"].some((command) => {
|
|
23
|
+
if (!head.startsWith(command)) return false;
|
|
24
|
+
const nextChar = head[command.length];
|
|
25
|
+
return nextChar === undefined || /\s|:/.test(nextChar);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getMarkdownLineEnd(markdown: string, offset: number): number {
|
|
30
|
+
const newlineIndex = markdown.indexOf("\n", offset);
|
|
31
|
+
return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getMarkdownLineText(
|
|
35
|
+
markdown: string,
|
|
36
|
+
offset: number,
|
|
37
|
+
end: number,
|
|
38
|
+
): string {
|
|
39
|
+
return markdown.slice(offset, end).replace(/\r?\n$/, "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getTopLevelOpeningFence(
|
|
43
|
+
line: string,
|
|
44
|
+
): TelegramTopLevelFenceState | undefined {
|
|
45
|
+
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
|
|
46
|
+
const sequence = match?.[1];
|
|
47
|
+
if (!sequence) return undefined;
|
|
48
|
+
return {
|
|
49
|
+
marker: sequence[0] as "`" | "~",
|
|
50
|
+
length: sequence.length,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isTopLevelClosingFence(
|
|
55
|
+
line: string,
|
|
56
|
+
fence: TelegramTopLevelFenceState,
|
|
57
|
+
): boolean {
|
|
58
|
+
const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
|
|
59
|
+
const sequence = match?.[1];
|
|
60
|
+
return (
|
|
61
|
+
!!sequence &&
|
|
62
|
+
sequence[0] === fence.marker &&
|
|
63
|
+
sequence.length >= fence.length
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function collectInlineClosedTelegramActionBody(
|
|
68
|
+
markdown: string,
|
|
69
|
+
bodyStart: number,
|
|
70
|
+
commentContent: string,
|
|
71
|
+
): { content: string; end: number } | undefined {
|
|
72
|
+
const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
|
|
73
|
+
const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
|
|
74
|
+
const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
|
|
75
|
+
const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
|
|
76
|
+
const hasRecoverableBody =
|
|
77
|
+
isTelegramActionCommentContent(commentContent) &&
|
|
78
|
+
bodyLine.trim() !== "" &&
|
|
79
|
+
!bodyLine.startsWith("<!--") &&
|
|
80
|
+
!bodyLine.startsWith("-->") &&
|
|
81
|
+
closeLine === "-->";
|
|
82
|
+
if (!hasRecoverableBody) return undefined;
|
|
83
|
+
return {
|
|
84
|
+
content: `${commentContent.trimEnd()}\n${bodyLine}`,
|
|
85
|
+
end: bodyLineEnd + 3,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function collectTopLevelHtmlComments(markdown: string): {
|
|
90
|
+
comments: TelegramTopLevelHtmlComment[];
|
|
91
|
+
openCommentStart?: number;
|
|
92
|
+
} {
|
|
93
|
+
const comments: TelegramTopLevelHtmlComment[] = [];
|
|
94
|
+
let offset = 0;
|
|
95
|
+
let fence: TelegramTopLevelFenceState | undefined;
|
|
96
|
+
while (offset < markdown.length) {
|
|
97
|
+
const lineEnd = getMarkdownLineEnd(markdown, offset);
|
|
98
|
+
const line = getMarkdownLineText(markdown, offset, lineEnd);
|
|
99
|
+
if (fence) {
|
|
100
|
+
if (isTopLevelClosingFence(line, fence)) fence = undefined;
|
|
101
|
+
offset = lineEnd;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const nextFence = getTopLevelOpeningFence(line);
|
|
105
|
+
if (nextFence) {
|
|
106
|
+
fence = nextFence;
|
|
107
|
+
offset = lineEnd;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (line.startsWith("<!--")) {
|
|
111
|
+
const closeIndex = markdown.indexOf("-->", offset + 4);
|
|
112
|
+
if (closeIndex === -1) return { comments, openCommentStart: offset };
|
|
113
|
+
let end = closeIndex + 3;
|
|
114
|
+
let raw = markdown.slice(offset, end);
|
|
115
|
+
let content = raw.slice(4, -3);
|
|
116
|
+
const closeColumn = closeIndex - offset;
|
|
117
|
+
const closesOnOpeningLine = closeIndex < lineEnd;
|
|
118
|
+
const hasOnlyWhitespaceAfterClose =
|
|
119
|
+
line.slice(closeColumn + 3).trim() === "";
|
|
120
|
+
const inlineBody =
|
|
121
|
+
closesOnOpeningLine && hasOnlyWhitespaceAfterClose
|
|
122
|
+
? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
|
|
123
|
+
: undefined;
|
|
124
|
+
if (inlineBody) {
|
|
125
|
+
end = inlineBody.end;
|
|
126
|
+
raw = markdown.slice(offset, end);
|
|
127
|
+
content = inlineBody.content;
|
|
128
|
+
}
|
|
129
|
+
comments.push({ raw, content, start: offset, end });
|
|
130
|
+
offset = getMarkdownLineEnd(markdown, end);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
offset = lineEnd;
|
|
134
|
+
}
|
|
135
|
+
return { comments };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function replaceTopLevelHtmlComments(
|
|
139
|
+
markdown: string,
|
|
140
|
+
replacer: (comment: TelegramTopLevelHtmlComment) => string,
|
|
141
|
+
): string {
|
|
142
|
+
const { comments } = collectTopLevelHtmlComments(markdown);
|
|
143
|
+
if (comments.length === 0) return markdown;
|
|
144
|
+
let result = "";
|
|
145
|
+
let offset = 0;
|
|
146
|
+
for (const comment of comments) {
|
|
147
|
+
result += markdown.slice(offset, comment.start);
|
|
148
|
+
result += replacer(comment);
|
|
149
|
+
offset = comment.end;
|
|
150
|
+
}
|
|
151
|
+
return result + markdown.slice(offset);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function findTopLevelOpenOrPartialHtmlCommentIndex(
|
|
155
|
+
markdown: string,
|
|
156
|
+
): number {
|
|
157
|
+
const { openCommentStart } = collectTopLevelHtmlComments(markdown);
|
|
158
|
+
if (openCommentStart !== undefined) return openCommentStart;
|
|
159
|
+
let offset = 0;
|
|
160
|
+
let fence: TelegramTopLevelFenceState | undefined;
|
|
161
|
+
while (offset < markdown.length) {
|
|
162
|
+
const lineEnd = getMarkdownLineEnd(markdown, offset);
|
|
163
|
+
const line = getMarkdownLineText(markdown, offset, lineEnd);
|
|
164
|
+
const isLastLine = lineEnd >= markdown.length;
|
|
165
|
+
if (fence) {
|
|
166
|
+
if (isTopLevelClosingFence(line, fence)) fence = undefined;
|
|
167
|
+
offset = lineEnd;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const nextFence = getTopLevelOpeningFence(line);
|
|
171
|
+
if (nextFence) {
|
|
172
|
+
fence = nextFence;
|
|
173
|
+
offset = lineEnd;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
|
|
177
|
+
return offset;
|
|
178
|
+
}
|
|
179
|
+
offset = lineEnd;
|
|
180
|
+
}
|
|
181
|
+
return -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function parseTopLevelTelegramComment(
|
|
185
|
+
comment: TelegramTopLevelHtmlComment,
|
|
186
|
+
command: string,
|
|
187
|
+
): { head: string; body?: string } | undefined {
|
|
188
|
+
let normalizedContent = comment.content.replace(/^\s+/, "");
|
|
189
|
+
normalizedContent = normalizedContent.replace(/^!/, "");
|
|
190
|
+
const [rawHead = "", ...bodyLines] = normalizedContent.split(/\r?\n/);
|
|
191
|
+
let head = rawHead.trimStart();
|
|
192
|
+
if (!head.startsWith(command)) return undefined;
|
|
193
|
+
const nextChar = head[command.length];
|
|
194
|
+
if (nextChar !== undefined && !/\s|:/.test(nextChar)) return undefined;
|
|
195
|
+
return {
|
|
196
|
+
head: head.slice(command.length),
|
|
197
|
+
...(bodyLines.length > 0 ? { body: bodyLines.join("\n") } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseTelegramCommentAttributes(
|
|
202
|
+
input: string,
|
|
203
|
+
): Record<string, string> {
|
|
204
|
+
const attributes: Record<string, string> = {};
|
|
205
|
+
for (const match of input.matchAll(
|
|
206
|
+
/([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
|
|
207
|
+
)) {
|
|
208
|
+
const key = match[1];
|
|
209
|
+
const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
|
|
210
|
+
if (value) attributes[key] = value;
|
|
211
|
+
}
|
|
212
|
+
return attributes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function normalizeMarkdownAfterVoiceExtraction(
|
|
216
|
+
markdown: string,
|
|
217
|
+
): string {
|
|
218
|
+
return markdown.replace(/\n{3,}/g, "\n\n").trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function stripTelegramCommentMarkupForPreview(markdown: string): string {
|
|
222
|
+
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
|
|
223
|
+
const openBlockIndex =
|
|
224
|
+
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
|
|
225
|
+
const previewMarkdown =
|
|
226
|
+
openBlockIndex >= 0
|
|
227
|
+
? withoutClosedBlocks.slice(0, openBlockIndex)
|
|
228
|
+
: withoutClosedBlocks;
|
|
229
|
+
return normalizeMarkdownAfterVoiceExtraction(previewMarkdown);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function stripTelegramCommentMarkupForDelivery(
|
|
233
|
+
markdown: string,
|
|
234
|
+
): string {
|
|
235
|
+
const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
|
|
236
|
+
const openBlockIndex =
|
|
237
|
+
findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
|
|
238
|
+
const deliveryMarkdown =
|
|
239
|
+
openBlockIndex >= 0
|
|
240
|
+
? withoutClosedBlocks.slice(0, openBlockIndex)
|
|
241
|
+
: withoutClosedBlocks;
|
|
242
|
+
return normalizeMarkdownAfterVoiceExtraction(deliveryMarkdown);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function stripTelegramVoiceMarkupForPreview(markdown: string): string {
|
|
246
|
+
return stripTelegramCommentMarkupForPreview(markdown);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface TelegramVoiceReplyItem {
|
|
250
|
+
text: string;
|
|
251
|
+
lang?: string;
|
|
252
|
+
rate?: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface TelegramVoiceReplyPlan {
|
|
256
|
+
markdown: string;
|
|
257
|
+
voiceText?: string;
|
|
258
|
+
voiceReplies?: TelegramVoiceReplyItem[];
|
|
259
|
+
lang?: string;
|
|
260
|
+
rate?: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseVoiceReplyAttributes(input: string): {
|
|
264
|
+
lang?: string;
|
|
265
|
+
rate?: string;
|
|
266
|
+
text?: string;
|
|
267
|
+
} {
|
|
268
|
+
const attributes = parseTelegramCommentAttributes(input);
|
|
269
|
+
return {
|
|
270
|
+
...(attributes.lang ? { lang: attributes.lang } : {}),
|
|
271
|
+
...(attributes.rate ? { rate: attributes.rate } : {}),
|
|
272
|
+
...(attributes.text ? { text: attributes.text } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseVoiceCommentBody(
|
|
277
|
+
head: string,
|
|
278
|
+
body: string | undefined,
|
|
279
|
+
): {
|
|
280
|
+
attrs: string;
|
|
281
|
+
text: string;
|
|
282
|
+
} {
|
|
283
|
+
const trimmedHead = head.trim();
|
|
284
|
+
if (body !== undefined) {
|
|
285
|
+
return { attrs: trimmedHead.replace(/^:/, "").trim(), text: body.trim() };
|
|
286
|
+
}
|
|
287
|
+
let colonIndex = -1;
|
|
288
|
+
let inQuote = false;
|
|
289
|
+
let quoteChar = "";
|
|
290
|
+
for (let i = 0; i < trimmedHead.length; i++) {
|
|
291
|
+
const char = trimmedHead[i];
|
|
292
|
+
if (inQuote) {
|
|
293
|
+
if (char === quoteChar) inQuote = false;
|
|
294
|
+
} else {
|
|
295
|
+
if (char === '"' || char === "'") {
|
|
296
|
+
inQuote = true;
|
|
297
|
+
quoteChar = char;
|
|
298
|
+
} else if (char === ":") {
|
|
299
|
+
colonIndex = i;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (colonIndex > 0) {
|
|
305
|
+
const attrsPart = trimmedHead.slice(0, colonIndex).trim();
|
|
306
|
+
const textPart = trimmedHead.slice(colonIndex + 1).trim();
|
|
307
|
+
const attrs = parseVoiceReplyAttributes(attrsPart);
|
|
308
|
+
return { attrs: attrsPart, text: textPart || attrs.text || "", ...attrs };
|
|
309
|
+
}
|
|
310
|
+
if (trimmedHead.startsWith(":")) {
|
|
311
|
+
return { attrs: "", text: trimmedHead.slice(1).trim() };
|
|
312
|
+
}
|
|
313
|
+
const attrs = parseVoiceReplyAttributes(trimmedHead);
|
|
314
|
+
return { attrs: trimmedHead, text: attrs.text ?? "" };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function planTelegramVoiceReply(
|
|
318
|
+
markdown: string,
|
|
319
|
+
): TelegramVoiceReplyPlan {
|
|
320
|
+
const voiceReplies: TelegramVoiceReplyItem[] = [];
|
|
321
|
+
let lang: string | undefined;
|
|
322
|
+
let rate: string | undefined;
|
|
323
|
+
const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
|
|
324
|
+
let command = parseTopLevelTelegramComment(comment, "telegram_voice");
|
|
325
|
+
if (!command) {
|
|
326
|
+
let content = comment.content.replace(/^\s+/, "").replace(/^!/, "");
|
|
327
|
+
if (content.startsWith("telegram_voice")) {
|
|
328
|
+
const headPart = content.slice("telegram_voice".length).trim();
|
|
329
|
+
command = { head: headPart, body: undefined };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (!command) return "";
|
|
333
|
+
const parsed = parseVoiceCommentBody(command.head, command.body);
|
|
334
|
+
const attrs = parseVoiceReplyAttributes(parsed.attrs);
|
|
335
|
+
if (parsed.text) {
|
|
336
|
+
voiceReplies.push({
|
|
337
|
+
text: parsed.text,
|
|
338
|
+
...(attrs.lang ? { lang: attrs.lang } : {}),
|
|
339
|
+
...(attrs.rate ? { rate: attrs.rate } : {}),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
if (attrs.lang) lang = attrs.lang;
|
|
343
|
+
if (attrs.rate) rate = attrs.rate;
|
|
344
|
+
return "";
|
|
345
|
+
});
|
|
346
|
+
const voiceText = voiceReplies
|
|
347
|
+
.map((reply) => reply.text)
|
|
348
|
+
.join("\n\n")
|
|
349
|
+
.trim();
|
|
350
|
+
return {
|
|
351
|
+
markdown: stripTelegramCommentMarkupForDelivery(stripped),
|
|
352
|
+
...(voiceText ? { voiceText } : {}),
|
|
353
|
+
...(voiceReplies.length > 0 ? { voiceReplies } : {}),
|
|
354
|
+
...(lang ? { lang } : {}),
|
|
355
|
+
...(rate ? { rate } : {}),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram outbound voice delivery helpers
|
|
3
|
+
* Zones: telegram outbound, voice delivery
|
|
4
|
+
* Owns native Telegram voice upload orchestration across configured voice handlers, programmatic outbound voice handlers, and registered synthesis providers
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { unlink } from "node:fs/promises";
|
|
8
|
+
import { basename, extname } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
|
|
11
|
+
|
|
12
|
+
export interface TelegramVoiceReplyTurnView {
|
|
13
|
+
chatId: number;
|
|
14
|
+
replyToMessageId: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TelegramVoiceReplySenderDeps {
|
|
18
|
+
execCommand: (
|
|
19
|
+
command: string,
|
|
20
|
+
args: string[],
|
|
21
|
+
options?: {
|
|
22
|
+
cwd?: string;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
stdin?: string;
|
|
26
|
+
retry?: number;
|
|
27
|
+
},
|
|
28
|
+
) => Promise<{ stdout: string; stderr: string; code: number; killed: boolean }>;
|
|
29
|
+
sendMultipart: (
|
|
30
|
+
method: string,
|
|
31
|
+
fields: Record<string, string>,
|
|
32
|
+
fileField: string,
|
|
33
|
+
filePath: string,
|
|
34
|
+
fileName: string,
|
|
35
|
+
) => Promise<unknown>;
|
|
36
|
+
sendChatAction?: (chatId: number, action: string) => Promise<unknown>;
|
|
37
|
+
sendRecordVoiceAction?: (chatId: number) => Promise<unknown>;
|
|
38
|
+
getHandlers?: () => unknown[] | undefined;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
tempDir?: string;
|
|
41
|
+
recordRuntimeEvent?: (
|
|
42
|
+
category: string,
|
|
43
|
+
error: unknown,
|
|
44
|
+
details?: Record<string, unknown>,
|
|
45
|
+
) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type TelegramOutboundProgrammaticVoiceHandler = (
|
|
49
|
+
text: string,
|
|
50
|
+
options?: { lang?: string; rate?: string },
|
|
51
|
+
) => Promise<string>;
|
|
52
|
+
|
|
53
|
+
export interface TelegramVoiceReplySenderPorts<THandler = unknown> {
|
|
54
|
+
findVoiceHandlers?: (handlers: unknown[] | undefined) => THandler[];
|
|
55
|
+
generateVoiceFile?: (
|
|
56
|
+
text: string,
|
|
57
|
+
options: {
|
|
58
|
+
lang?: string;
|
|
59
|
+
rate?: string;
|
|
60
|
+
handler: THandler;
|
|
61
|
+
tempDir?: string;
|
|
62
|
+
cwd?: string;
|
|
63
|
+
execCommand: TelegramVoiceReplySenderDeps["execCommand"];
|
|
64
|
+
},
|
|
65
|
+
) => Promise<string | undefined>;
|
|
66
|
+
getProgrammaticVoiceHandlers?: () => TelegramOutboundProgrammaticVoiceHandler[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildVoiceReplyParameters(
|
|
70
|
+
replyToPrompt: boolean | undefined,
|
|
71
|
+
replyToMessageId: number | undefined,
|
|
72
|
+
): string | undefined {
|
|
73
|
+
if (replyToPrompt === false || replyToMessageId === undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
return JSON.stringify({
|
|
76
|
+
message_id: replyToMessageId,
|
|
77
|
+
allow_sending_without_reply: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ensureTelegramVoiceFileFormat(
|
|
82
|
+
filePath: string,
|
|
83
|
+
): Promise<string> {
|
|
84
|
+
const ext = extname(filePath).toLowerCase();
|
|
85
|
+
if (ext === ".opus" || ext === ".ogg") return filePath;
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Voice synthesis provider must return .ogg or .opus files, got ${ext}. ` +
|
|
88
|
+
`Providers should handle format conversion internally.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function extractVoiceResult(result: any): {
|
|
93
|
+
filePath: string;
|
|
94
|
+
transcriptText?: string;
|
|
95
|
+
} {
|
|
96
|
+
if (typeof result === "string") return { filePath: result };
|
|
97
|
+
return {
|
|
98
|
+
filePath: result.audioPath,
|
|
99
|
+
transcriptText: result.transcriptText,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function sendVoiceChatAction(
|
|
104
|
+
deps: TelegramVoiceReplySenderDeps,
|
|
105
|
+
chatId: number,
|
|
106
|
+
) {
|
|
107
|
+
if (deps.sendRecordVoiceAction) {
|
|
108
|
+
await deps.sendRecordVoiceAction(chatId).catch(() => {});
|
|
109
|
+
} else {
|
|
110
|
+
await deps.sendChatAction?.(chatId, "record_voice").catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createTelegramVoiceReplySender<THandler = unknown>(
|
|
115
|
+
deps: TelegramVoiceReplySenderDeps,
|
|
116
|
+
ports: TelegramVoiceReplySenderPorts<THandler> = {},
|
|
117
|
+
) {
|
|
118
|
+
async function uploadVoiceFile(
|
|
119
|
+
turn: TelegramVoiceReplyTurnView,
|
|
120
|
+
filePath: string,
|
|
121
|
+
options?: {
|
|
122
|
+
replyToPrompt?: boolean;
|
|
123
|
+
replyMarkup?: unknown;
|
|
124
|
+
transcriptText?: string;
|
|
125
|
+
},
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
|
|
128
|
+
await sendVoiceChatAction(deps, turn.chatId);
|
|
129
|
+
const replyParameters = buildVoiceReplyParameters(
|
|
130
|
+
options?.replyToPrompt,
|
|
131
|
+
turn.replyToMessageId,
|
|
132
|
+
);
|
|
133
|
+
await deps.sendMultipart(
|
|
134
|
+
"sendVoice",
|
|
135
|
+
{
|
|
136
|
+
chat_id: String(turn.chatId),
|
|
137
|
+
...(options?.transcriptText ? { caption: options.transcriptText } : {}),
|
|
138
|
+
...(replyParameters ? { reply_parameters: replyParameters } : {}),
|
|
139
|
+
...(options?.replyMarkup !== undefined && options.replyMarkup !== null
|
|
140
|
+
? {
|
|
141
|
+
reply_markup:
|
|
142
|
+
typeof options.replyMarkup === "string"
|
|
143
|
+
? options.replyMarkup
|
|
144
|
+
: JSON.stringify(options.replyMarkup),
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
147
|
+
},
|
|
148
|
+
"voice",
|
|
149
|
+
voiceFilePath,
|
|
150
|
+
basename(voiceFilePath),
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return async function sendVoiceReply(
|
|
155
|
+
turn: TelegramVoiceReplyTurnView,
|
|
156
|
+
text: string,
|
|
157
|
+
options?: {
|
|
158
|
+
lang?: string;
|
|
159
|
+
rate?: string;
|
|
160
|
+
replyToPrompt?: boolean;
|
|
161
|
+
replyMarkup?: unknown;
|
|
162
|
+
},
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
for (const handler of ports.findVoiceHandlers?.(deps.getHandlers?.()) ?? []) {
|
|
165
|
+
try {
|
|
166
|
+
const filePath = await ports.generateVoiceFile?.(text, {
|
|
167
|
+
lang: options?.lang,
|
|
168
|
+
rate: options?.rate,
|
|
169
|
+
handler,
|
|
170
|
+
tempDir: deps.tempDir,
|
|
171
|
+
cwd: deps.cwd,
|
|
172
|
+
execCommand: deps.execCommand,
|
|
173
|
+
});
|
|
174
|
+
if (!filePath) continue;
|
|
175
|
+
await uploadVoiceFile(turn, filePath, {
|
|
176
|
+
replyToPrompt: options?.replyToPrompt,
|
|
177
|
+
replyMarkup: options?.replyMarkup,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
deps.recordRuntimeEvent?.("voice", error, {
|
|
182
|
+
phase: "template-handler-send",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const handler of ports.getProgrammaticVoiceHandlers?.() ?? []) {
|
|
188
|
+
try {
|
|
189
|
+
const filePath = await handler(text, {
|
|
190
|
+
lang: options?.lang,
|
|
191
|
+
rate: options?.rate,
|
|
192
|
+
});
|
|
193
|
+
if (!filePath) continue;
|
|
194
|
+
await uploadVoiceFile(turn, filePath, {
|
|
195
|
+
replyToPrompt: options?.replyToPrompt,
|
|
196
|
+
replyMarkup: options?.replyMarkup,
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
} catch (error) {
|
|
200
|
+
deps.recordRuntimeEvent?.("voice", error, {
|
|
201
|
+
phase: "programmatic-handler-send",
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const providers = getTelegramVoiceSynthesisProviders();
|
|
207
|
+
|
|
208
|
+
for (const provider of providers) {
|
|
209
|
+
let voiceFilePath: string | undefined;
|
|
210
|
+
let originalFilePath: string | undefined;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
if (typeof provider !== "function") {
|
|
214
|
+
deps.recordRuntimeEvent?.(
|
|
215
|
+
"voice",
|
|
216
|
+
new Error(
|
|
217
|
+
"Registered voice synthesis provider is not callable (policy-only object?)",
|
|
218
|
+
),
|
|
219
|
+
{ phase: "voice-provider-skip" },
|
|
220
|
+
);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const providerResult = await provider(text, {
|
|
225
|
+
lang: options?.lang,
|
|
226
|
+
rate: options?.rate,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
if (!providerResult) {
|
|
230
|
+
deps.recordRuntimeEvent?.(
|
|
231
|
+
"voice",
|
|
232
|
+
new Error("Voice synthesis provider returned empty path"),
|
|
233
|
+
{ phase: "voice-provider-skip" },
|
|
234
|
+
);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const { filePath, transcriptText } = extractVoiceResult(providerResult);
|
|
239
|
+
voiceFilePath = filePath;
|
|
240
|
+
originalFilePath = filePath;
|
|
241
|
+
await uploadVoiceFile(turn, filePath, {
|
|
242
|
+
replyToPrompt: options?.replyToPrompt,
|
|
243
|
+
replyMarkup: options?.replyMarkup,
|
|
244
|
+
transcriptText,
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
deps.recordRuntimeEvent?.("voice", error, { phase: "send" });
|
|
249
|
+
} finally {
|
|
250
|
+
if (voiceFilePath && voiceFilePath !== originalFilePath) {
|
|
251
|
+
await unlink(voiceFilePath).catch(() => {});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const errorMessage =
|
|
257
|
+
"Failed to send voice reply: every voice synthesis provider and outbound voice handler failed.";
|
|
258
|
+
deps.recordRuntimeEvent?.("voice", new Error(errorMessage), {
|
|
259
|
+
phase: "send",
|
|
260
|
+
});
|
|
261
|
+
throw new Error(errorMessage);
|
|
262
|
+
};
|
|
263
|
+
}
|