@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
|
@@ -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
|
+
}
|