@llblab/pi-telegram 0.10.8 → 0.11.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 +9 -6
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +22 -1
- package/README.md +47 -23
- package/docs/README.md +3 -2
- package/docs/architecture.md +3 -3
- 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 +75 -1
- package/lib/api.ts +35 -2
- package/lib/config.ts +70 -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 +134 -8
- 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 +2 -1
- package/lib/queue.ts +84 -5
- package/lib/routing.ts +10 -1
- package/lib/turns.ts +84 -20
- package/lib/voice.ts +295 -0
- package/package.json +1 -1
package/lib/voice.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice Domain
|
|
3
|
+
* Zones: telegram, voice
|
|
4
|
+
*
|
|
5
|
+
* This module is the single owner of all Voice-specific coordination logic:
|
|
6
|
+
* - Voice reply policy (mirror / voice / manual) via getTelegramVoiceReplyMode()
|
|
7
|
+
* - Voice turn tagging (voiceReplyPreferred / voiceReplyRequired)
|
|
8
|
+
* - Voice-specific prompt contributions for the LLM
|
|
9
|
+
* - Voice synthesis provider registry (registration + policy/prompt hooks)
|
|
10
|
+
* - Voice markup parsing (planTelegramVoiceReply + helpers)
|
|
11
|
+
* - Voice suppression helpers (isVoiceTurn, shouldSuppressPreviewForVoice)
|
|
12
|
+
*
|
|
13
|
+
* Separation of concerns:
|
|
14
|
+
* - All decision logic and domain rules live here.
|
|
15
|
+
* - Actual delivery (sending the audio via Telegram) stays in outbound-handlers.ts.
|
|
16
|
+
*
|
|
17
|
+
* Keeps voice policy, turn tagging, prompt contributions, and markup helpers
|
|
18
|
+
* out of the queue, preview, turn-building, and delivery domains.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY = "__piTelegramVoiceSynthesisProviders__";
|
|
22
|
+
const VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY =
|
|
23
|
+
"__piTelegramVoiceTranscriptionProviders__";
|
|
24
|
+
|
|
25
|
+
export type TelegramVoiceReplyMode = "mirror" | "always" | "manual";
|
|
26
|
+
|
|
27
|
+
export type TelegramVoiceSynthesisProviderResult =
|
|
28
|
+
| string
|
|
29
|
+
| {
|
|
30
|
+
audioPath: string;
|
|
31
|
+
transcriptText?: string;
|
|
32
|
+
}
|
|
33
|
+
| undefined;
|
|
34
|
+
|
|
35
|
+
export interface TelegramVoiceTurnView {
|
|
36
|
+
voiceReplyPreferred?: boolean;
|
|
37
|
+
voiceReplyRequired?: boolean;
|
|
38
|
+
hasVoiceInput?: boolean;
|
|
39
|
+
userText?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TelegramVoiceSynthesisProvider {
|
|
43
|
+
(
|
|
44
|
+
text: string,
|
|
45
|
+
options?: { lang?: string; rate?: string },
|
|
46
|
+
): Promise<TelegramVoiceSynthesisProviderResult>;
|
|
47
|
+
getVoicePolicy?: () => { replyMode?: TelegramVoiceReplyMode };
|
|
48
|
+
getVoicePromptContribution?: (
|
|
49
|
+
view: TelegramVoiceTurnView,
|
|
50
|
+
) => string | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type TelegramVoiceTranscriptionProviderResult =
|
|
54
|
+
| string
|
|
55
|
+
| { text: string; language?: string }
|
|
56
|
+
| undefined;
|
|
57
|
+
|
|
58
|
+
export interface TelegramVoiceTranscriptionFile {
|
|
59
|
+
path: string;
|
|
60
|
+
fileName?: string;
|
|
61
|
+
mimeType?: string;
|
|
62
|
+
kind?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TelegramVoiceTranscriptionProvider {
|
|
66
|
+
(
|
|
67
|
+
file: TelegramVoiceTranscriptionFile,
|
|
68
|
+
options?: { language?: string },
|
|
69
|
+
): Promise<TelegramVoiceTranscriptionProviderResult>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Voice Synthesis Provider Registry ---
|
|
73
|
+
|
|
74
|
+
function getOrCreateVoiceSynthesisProviderRegistry(): Map<
|
|
75
|
+
string,
|
|
76
|
+
TelegramVoiceSynthesisProvider
|
|
77
|
+
> {
|
|
78
|
+
const existing = (globalThis as Record<string, unknown>)[
|
|
79
|
+
VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
|
|
80
|
+
];
|
|
81
|
+
if (existing instanceof Map)
|
|
82
|
+
return existing as Map<string, TelegramVoiceSynthesisProvider>;
|
|
83
|
+
const registry = new Map<string, TelegramVoiceSynthesisProvider>();
|
|
84
|
+
(globalThis as Record<string, unknown>)[VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY] =
|
|
85
|
+
registry;
|
|
86
|
+
return registry;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function registerTelegramVoiceSynthesisProvider(
|
|
90
|
+
provider:
|
|
91
|
+
| TelegramVoiceSynthesisProvider
|
|
92
|
+
| ((
|
|
93
|
+
text: string,
|
|
94
|
+
options?: { lang?: string; rate?: string },
|
|
95
|
+
) => Promise<TelegramVoiceSynthesisProviderResult>),
|
|
96
|
+
options?: { id?: string },
|
|
97
|
+
): () => void {
|
|
98
|
+
const registry = getOrCreateVoiceSynthesisProviderRegistry();
|
|
99
|
+
const id = options?.id ?? `voice-synthesis-provider-${registry.size}`;
|
|
100
|
+
const normalized =
|
|
101
|
+
typeof provider === "function"
|
|
102
|
+
? (Object.assign(
|
|
103
|
+
(text: string, options?: { lang?: string; rate?: string }) =>
|
|
104
|
+
provider(text, options),
|
|
105
|
+
{
|
|
106
|
+
getVoicePolicy: (provider as TelegramVoiceSynthesisProvider).getVoicePolicy,
|
|
107
|
+
getVoicePromptContribution: (provider as TelegramVoiceSynthesisProvider)
|
|
108
|
+
.getVoicePromptContribution,
|
|
109
|
+
},
|
|
110
|
+
) as TelegramVoiceSynthesisProvider)
|
|
111
|
+
: provider;
|
|
112
|
+
registry.set(id, normalized);
|
|
113
|
+
return () => {
|
|
114
|
+
registry.delete(id);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function getTelegramVoiceSynthesisProviders(): TelegramVoiceSynthesisProvider[] {
|
|
119
|
+
return Array.from(getOrCreateVoiceSynthesisProviderRegistry().values());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function hasTelegramVoiceSynthesisProvider(): boolean {
|
|
123
|
+
return getOrCreateVoiceSynthesisProviderRegistry().size > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function clearTelegramVoiceSynthesisProviders(): void {
|
|
127
|
+
getOrCreateVoiceSynthesisProviderRegistry().clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getOrCreateVoiceTranscriptionProviderRegistry(): Map<
|
|
131
|
+
string,
|
|
132
|
+
TelegramVoiceTranscriptionProvider
|
|
133
|
+
> {
|
|
134
|
+
const existing = (globalThis as Record<string, unknown>)[
|
|
135
|
+
VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY
|
|
136
|
+
];
|
|
137
|
+
if (existing instanceof Map) {
|
|
138
|
+
return existing as Map<string, TelegramVoiceTranscriptionProvider>;
|
|
139
|
+
}
|
|
140
|
+
const registry = new Map<string, TelegramVoiceTranscriptionProvider>();
|
|
141
|
+
(globalThis as Record<string, unknown>)[
|
|
142
|
+
VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY
|
|
143
|
+
] = registry;
|
|
144
|
+
return registry;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function registerTelegramVoiceTranscriptionProvider(
|
|
148
|
+
provider: TelegramVoiceTranscriptionProvider,
|
|
149
|
+
options?: { id?: string },
|
|
150
|
+
): () => void {
|
|
151
|
+
const registry = getOrCreateVoiceTranscriptionProviderRegistry();
|
|
152
|
+
const id = options?.id ?? `voice-transcription-provider-${registry.size}`;
|
|
153
|
+
registry.set(id, provider);
|
|
154
|
+
return () => {
|
|
155
|
+
registry.delete(id);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function getTelegramVoiceTranscriptionProviders(): TelegramVoiceTranscriptionProvider[] {
|
|
160
|
+
return Array.from(getOrCreateVoiceTranscriptionProviderRegistry().values());
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function hasTelegramVoiceTranscriptionProvider(): boolean {
|
|
164
|
+
return getOrCreateVoiceTranscriptionProviderRegistry().size > 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function clearTelegramVoiceTranscriptionProviders(): void {
|
|
168
|
+
getOrCreateVoiceTranscriptionProviderRegistry().clear();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- Voice Reply Modes ---
|
|
172
|
+
|
|
173
|
+
export const TELEGRAM_VOICE_REPLY_MODES = [
|
|
174
|
+
"mirror",
|
|
175
|
+
"always",
|
|
176
|
+
"manual",
|
|
177
|
+
] as const;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Returns the active voice reply mode for the current session.
|
|
181
|
+
*
|
|
182
|
+
* Pi-telegram owns reply-mode policy through telegram.json. If
|
|
183
|
+
* config.voice.replyMode is missing or invalid, the safe default is manual.
|
|
184
|
+
*/
|
|
185
|
+
export function getTelegramVoiceReplyMode(
|
|
186
|
+
config?: { voice?: { replyMode?: string } },
|
|
187
|
+
): TelegramVoiceReplyMode {
|
|
188
|
+
const configMode = config?.voice?.replyMode;
|
|
189
|
+
if (
|
|
190
|
+
configMode &&
|
|
191
|
+
(TELEGRAM_VOICE_REPLY_MODES as readonly string[]).includes(configMode)
|
|
192
|
+
) {
|
|
193
|
+
return configMode as TelegramVoiceReplyMode;
|
|
194
|
+
}
|
|
195
|
+
return "manual";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Returns whether the user wants the voice synthesis provider's transcript attached
|
|
200
|
+
* as a caption on the voice message.
|
|
201
|
+
*
|
|
202
|
+
* Reads from `config.voice.sendTranscript`.
|
|
203
|
+
* Default: false (no transcript text sent at all).
|
|
204
|
+
*/
|
|
205
|
+
export function getTelegramVoiceSendTranscript(
|
|
206
|
+
config?: { voice?: { sendTranscript?: boolean } },
|
|
207
|
+
): boolean {
|
|
208
|
+
return !!config?.voice?.sendTranscript;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// --- Voice Turn Helpers ---
|
|
212
|
+
|
|
213
|
+
/** Small helper to compute the two voice flags from mode + hasVoiceFile */
|
|
214
|
+
export function computeVoiceTurnFlags(
|
|
215
|
+
voiceReplyMode: TelegramVoiceReplyMode | undefined,
|
|
216
|
+
hasVoiceFile: boolean,
|
|
217
|
+
) {
|
|
218
|
+
return {
|
|
219
|
+
voiceReplyPreferred: hasVoiceFile && voiceReplyMode === "mirror",
|
|
220
|
+
voiceReplyRequired: voiceReplyMode === "always",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Returns true if the given turn is tagged as a voice turn */
|
|
225
|
+
export function isVoiceTurn(
|
|
226
|
+
turn:
|
|
227
|
+
| { voiceReplyPreferred?: boolean; voiceReplyRequired?: boolean }
|
|
228
|
+
| null
|
|
229
|
+
| undefined,
|
|
230
|
+
): boolean {
|
|
231
|
+
return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Voice Prompt Contribution ---
|
|
235
|
+
|
|
236
|
+
export function computeVoicePromptContribution(
|
|
237
|
+
voiceReplyMode: TelegramVoiceReplyMode | undefined,
|
|
238
|
+
files: Array<{ kind?: string }>,
|
|
239
|
+
rawText: string,
|
|
240
|
+
): string | undefined {
|
|
241
|
+
const hasVoiceFile = files.some(
|
|
242
|
+
(f) => f.kind === "voice" || f.kind === "audio",
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const isVoiceTagged =
|
|
246
|
+
voiceReplyMode === "always" ||
|
|
247
|
+
(voiceReplyMode === "mirror" && hasVoiceFile);
|
|
248
|
+
|
|
249
|
+
if (!isVoiceTagged) return undefined;
|
|
250
|
+
|
|
251
|
+
const view: TelegramVoiceTurnView = {
|
|
252
|
+
...computeVoiceTurnFlags(voiceReplyMode, hasVoiceFile),
|
|
253
|
+
hasVoiceInput: hasVoiceFile,
|
|
254
|
+
userText: rawText,
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Let the voice synthesis provider supply additional instructions for the LLM when in voice mode.
|
|
258
|
+
// When multiple providers are registered, the first one (in registration order)
|
|
259
|
+
// that returns a non-empty string wins.
|
|
260
|
+
for (const provider of getTelegramVoiceSynthesisProviders()) {
|
|
261
|
+
if (typeof provider.getVoicePromptContribution === "function") {
|
|
262
|
+
const contribution = provider.getVoicePromptContribution(view);
|
|
263
|
+
if (contribution?.trim()) {
|
|
264
|
+
return contribution.trim();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Preview Suppression ---
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Returns true if the current turn should not show a text preview
|
|
276
|
+
* (e.g. because it's a voice reply).
|
|
277
|
+
*/
|
|
278
|
+
export function shouldSuppressPreviewForVoice(
|
|
279
|
+
turn:
|
|
280
|
+
| { voiceReplyPreferred?: boolean; voiceReplyRequired?: boolean }
|
|
281
|
+
| null
|
|
282
|
+
| undefined,
|
|
283
|
+
): boolean {
|
|
284
|
+
return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- Outbound Handler Re-Exports ---
|
|
288
|
+
|
|
289
|
+
export {
|
|
290
|
+
planTelegramVoiceReply,
|
|
291
|
+
stripTelegramCommentMarkupForPreview,
|
|
292
|
+
stripTelegramCommentMarkupForDelivery,
|
|
293
|
+
stripTelegramVoiceMarkupForPreview,
|
|
294
|
+
normalizeMarkdownAfterVoiceExtraction,
|
|
295
|
+
} from "./outbound-handlers.ts";
|