@llblab/pi-telegram 0.10.7 → 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 +10 -6
- package/BACKLOG.md +5 -0
- package/CHANGELOG.md +26 -1
- package/README.md +48 -24
- 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 +77 -1
- package/lib/api.ts +35 -2
- package/lib/commands.ts +18 -0
- 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 +14 -1
- package/lib/turns.ts +84 -20
- package/lib/voice.ts +295 -0
- package/package.json +1 -1
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
|
|
8
8
|
|
|
9
|
+
const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
|
|
10
|
+
|
|
9
11
|
// --- Core Types ---
|
|
10
12
|
|
|
13
|
+
/** @internal */
|
|
11
14
|
export type TelegramSectionId = string;
|
|
15
|
+
|
|
16
|
+
/** @internal */
|
|
12
17
|
export type TelegramSectionToken = string;
|
|
18
|
+
|
|
19
|
+
/** @internal */
|
|
13
20
|
export type TelegramSectionCallbackResult = "handled" | "pass";
|
|
14
21
|
|
|
15
22
|
export interface TelegramSectionView {
|
|
@@ -34,6 +41,7 @@ export interface TelegramSectionRegistration {
|
|
|
34
41
|
id: TelegramSectionId;
|
|
35
42
|
label: string;
|
|
36
43
|
order?: number;
|
|
44
|
+
getLabel?: () => string;
|
|
37
45
|
render: (
|
|
38
46
|
ctx: TelegramSectionContext,
|
|
39
47
|
) => TelegramSectionView | Promise<TelegramSectionView>;
|
|
@@ -71,6 +79,7 @@ export interface TelegramSectionCallbackContext {
|
|
|
71
79
|
deleteMessage(): Promise<void>;
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
/** @internal */
|
|
74
83
|
export interface RegisteredTelegramSection {
|
|
75
84
|
id: TelegramSectionId;
|
|
76
85
|
token: TelegramSectionToken;
|
|
@@ -79,6 +88,7 @@ export interface RegisteredTelegramSection {
|
|
|
79
88
|
registration: TelegramSectionRegistration;
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** @internal */
|
|
82
92
|
export interface TelegramSectionDiagnostic {
|
|
83
93
|
id: TelegramSectionId;
|
|
84
94
|
token: TelegramSectionToken;
|
|
@@ -87,6 +97,7 @@ export interface TelegramSectionDiagnostic {
|
|
|
87
97
|
lastError?: string;
|
|
88
98
|
}
|
|
89
99
|
|
|
100
|
+
/** @internal */
|
|
90
101
|
export interface TelegramSectionRegistry {
|
|
91
102
|
register(section: TelegramSectionRegistration): () => void;
|
|
92
103
|
getSections(): RegisteredTelegramSection[];
|
|
@@ -97,11 +108,13 @@ export interface TelegramSectionRegistry {
|
|
|
97
108
|
clear(): void;
|
|
98
109
|
}
|
|
99
110
|
|
|
111
|
+
/** @internal */
|
|
100
112
|
export interface TelegramSectionMainMenuRow {
|
|
101
113
|
text: string;
|
|
102
114
|
callback_data: string;
|
|
103
115
|
}
|
|
104
116
|
|
|
117
|
+
/** @internal */
|
|
105
118
|
export interface TelegramSectionSettingsRow {
|
|
106
119
|
label: string;
|
|
107
120
|
callback_data: string;
|
|
@@ -109,6 +122,7 @@ export interface TelegramSectionSettingsRow {
|
|
|
109
122
|
|
|
110
123
|
// --- Runtime Port Builders ---
|
|
111
124
|
|
|
125
|
+
/** @internal */
|
|
112
126
|
export interface TelegramSectionRuntimeDeps {
|
|
113
127
|
answerCallbackQuery: (id: string, text?: string) => Promise<void>;
|
|
114
128
|
editInteractiveMessage: (
|
|
@@ -229,17 +243,11 @@ function buildTelegramSectionCallbackContext(
|
|
|
229
243
|
|
|
230
244
|
// --- GlobalThis Bridge ---
|
|
231
245
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
declare global {
|
|
235
|
-
// eslint-disable-next-line no-var
|
|
236
|
-
var __piTelegramSectionRegistry__: TelegramSectionRegistry | undefined;
|
|
237
|
-
}
|
|
238
|
-
|
|
246
|
+
/** @internal */
|
|
239
247
|
export function setGlobalTelegramSectionRegistry(
|
|
240
248
|
registry: TelegramSectionRegistry,
|
|
241
249
|
): void {
|
|
242
|
-
globalThis[
|
|
250
|
+
(globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
|
|
243
251
|
}
|
|
244
252
|
|
|
245
253
|
/**
|
|
@@ -249,20 +257,26 @@ export function setGlobalTelegramSectionRegistry(
|
|
|
249
257
|
export function registerTelegramSection(
|
|
250
258
|
section: TelegramSectionRegistration,
|
|
251
259
|
): () => void {
|
|
252
|
-
const registry = globalThis[
|
|
260
|
+
const registry = (globalThis as Record<string, unknown>)[
|
|
261
|
+
SECTION_REGISTRY_KEY
|
|
262
|
+
] as TelegramSectionRegistry | undefined;
|
|
253
263
|
if (!registry) {
|
|
254
264
|
throw new Error(
|
|
255
|
-
"Telegram section registry not available. Is pi-telegram loaded?",
|
|
265
|
+
"Telegram section registry not available. Is pi-telegram loaded and initialized?",
|
|
256
266
|
);
|
|
257
267
|
}
|
|
268
|
+
|
|
258
269
|
return registry.register(section);
|
|
259
270
|
}
|
|
260
271
|
|
|
261
272
|
/**
|
|
262
273
|
* Get current section diagnostics. Returns empty array when registry is absent.
|
|
263
274
|
*/
|
|
275
|
+
/** @internal */
|
|
264
276
|
export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
|
|
265
|
-
const registry = globalThis[
|
|
277
|
+
const registry = (globalThis as Record<string, unknown>)[
|
|
278
|
+
SECTION_REGISTRY_KEY
|
|
279
|
+
] as TelegramSectionRegistry | undefined;
|
|
266
280
|
return registry ? registry.getDiagnostics() : [];
|
|
267
281
|
}
|
|
268
282
|
|
|
@@ -298,6 +312,7 @@ function prependBackRow(
|
|
|
298
312
|
};
|
|
299
313
|
}
|
|
300
314
|
|
|
315
|
+
/** @internal */
|
|
301
316
|
export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistry {
|
|
302
317
|
const sections = new Map<TelegramSectionToken, RegisteredTelegramSection>();
|
|
303
318
|
const errors = new Map<TelegramSectionToken, string>();
|
|
@@ -351,15 +366,7 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
|
|
|
351
366
|
return { register, getSections, getByToken, getDiagnostics, clear };
|
|
352
367
|
}
|
|
353
368
|
|
|
354
|
-
|
|
355
|
-
registry: TelegramSectionRegistry,
|
|
356
|
-
): TelegramSectionMainMenuRow[] {
|
|
357
|
-
return registry.getSections().map((s) => ({
|
|
358
|
-
text: s.label,
|
|
359
|
-
callback_data: `section:${s.token}:open`,
|
|
360
|
-
}));
|
|
361
|
-
}
|
|
362
|
-
|
|
369
|
+
/** @internal */
|
|
363
370
|
export function getTelegramExtensionSettingsRows(
|
|
364
371
|
registry: TelegramSectionRegistry,
|
|
365
372
|
): TelegramSectionSettingsRow[] {
|
|
@@ -379,6 +386,17 @@ export function getTelegramExtensionSettingsRows(
|
|
|
379
386
|
}));
|
|
380
387
|
}
|
|
381
388
|
|
|
389
|
+
/** @internal */
|
|
390
|
+
export function getTelegramSectionMainMenuRows(
|
|
391
|
+
registry: TelegramSectionRegistry,
|
|
392
|
+
): TelegramSectionMainMenuRow[] {
|
|
393
|
+
return registry.getSections().map((s) => ({
|
|
394
|
+
text: s.registration.getLabel?.() ?? s.label,
|
|
395
|
+
callback_data: `section:${s.token}:open`,
|
|
396
|
+
}));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** @internal */
|
|
382
400
|
export function parseTelegramSectionCallback(
|
|
383
401
|
data: string,
|
|
384
402
|
): { token: string; action: string; payload: string } | undefined {
|
|
@@ -399,6 +417,7 @@ export function parseTelegramSectionCallback(
|
|
|
399
417
|
};
|
|
400
418
|
}
|
|
401
419
|
|
|
420
|
+
/** @internal */
|
|
402
421
|
export interface TelegramSectionCallbackHandlerDeps {
|
|
403
422
|
answerCallbackQuery: (id: string, text?: string) => Promise<void>;
|
|
404
423
|
editInteractiveMessage: (
|
package/lib/external-handlers.ts
CHANGED
|
@@ -155,10 +155,9 @@ export function createTelegramExternalHandleUpdate<TUpdate, TContext>(
|
|
|
155
155
|
* off();
|
|
156
156
|
* ```
|
|
157
157
|
*
|
|
158
|
-
* Extensions that prefer zero coupling can also reach the registry
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* `@llblab/pi-telegram` and tolerates either install order.
|
|
158
|
+
* Extensions that prefer zero coupling can also reach the versioned registry
|
|
159
|
+
* directly on `globalThis`. This avoids importing `@llblab/pi-telegram` and
|
|
160
|
+
* tolerates either install order.
|
|
162
161
|
*/
|
|
163
162
|
export function onTelegramExternalUpdate(
|
|
164
163
|
handler: TelegramExternalHandler,
|
package/lib/inbound-handlers.ts
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
type CommandTemplateConfig,
|
|
15
15
|
type CommandTemplateObjectConfig,
|
|
16
16
|
} from "./command-templates.ts";
|
|
17
|
+
import { getTelegramVoiceTranscriptionProviders } from "./voice.ts";
|
|
17
18
|
|
|
18
19
|
const DEFAULT_INBOUND_HANDLER_TIMEOUT_MS = 120_000;
|
|
20
|
+
const INBOUND_HANDLER_REGISTRY_KEY = "__piTelegramInboundHandlers__";
|
|
19
21
|
|
|
20
22
|
type TelegramInboundCommandTemplateConfig =
|
|
21
23
|
| string
|
|
@@ -96,6 +98,27 @@ export interface TelegramInboundHandlerRuntime<TContext> {
|
|
|
96
98
|
) => Promise<TelegramInboundHandlerProcessResult<TFile>>;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
export type TelegramInboundProgrammaticHandlerResult =
|
|
102
|
+
| string
|
|
103
|
+
| { text: string }
|
|
104
|
+
| undefined;
|
|
105
|
+
|
|
106
|
+
export interface TelegramInboundProgrammaticHandlerInput {
|
|
107
|
+
kind: string;
|
|
108
|
+
text?: string;
|
|
109
|
+
file?: TelegramInboundHandlerFile;
|
|
110
|
+
mimeType?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type TelegramInboundProgrammaticHandler = (
|
|
114
|
+
input: TelegramInboundProgrammaticHandlerInput,
|
|
115
|
+
options?: { cwd?: string },
|
|
116
|
+
) => Promise<TelegramInboundProgrammaticHandlerResult>;
|
|
117
|
+
|
|
118
|
+
export interface TelegramInboundHandlerRegistry {
|
|
119
|
+
handlers: Map<string, TelegramInboundProgrammaticHandler[]>;
|
|
120
|
+
}
|
|
121
|
+
|
|
99
122
|
interface InboundHandlerInvocation {
|
|
100
123
|
command: string;
|
|
101
124
|
args: string[];
|
|
@@ -103,6 +126,74 @@ interface InboundHandlerInvocation {
|
|
|
103
126
|
|
|
104
127
|
const BUILT_IN_TEXT_ATTACHMENT_MAX_BYTES = 1_000_000;
|
|
105
128
|
|
|
129
|
+
function getOrCreateInboundHandlerRegistry(): TelegramInboundHandlerRegistry {
|
|
130
|
+
const existing = (globalThis as Record<string, unknown>)[
|
|
131
|
+
INBOUND_HANDLER_REGISTRY_KEY
|
|
132
|
+
];
|
|
133
|
+
if (
|
|
134
|
+
existing &&
|
|
135
|
+
typeof existing === "object" &&
|
|
136
|
+
existing !== null &&
|
|
137
|
+
"handlers" in existing &&
|
|
138
|
+
existing.handlers instanceof Map
|
|
139
|
+
) {
|
|
140
|
+
return existing as TelegramInboundHandlerRegistry;
|
|
141
|
+
}
|
|
142
|
+
const registry: TelegramInboundHandlerRegistry = {
|
|
143
|
+
handlers: new Map(),
|
|
144
|
+
};
|
|
145
|
+
(globalThis as Record<string, unknown>)[INBOUND_HANDLER_REGISTRY_KEY] =
|
|
146
|
+
registry;
|
|
147
|
+
return registry;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function registerTelegramInboundHandler(
|
|
151
|
+
kind: string,
|
|
152
|
+
handler: TelegramInboundProgrammaticHandler,
|
|
153
|
+
): () => void {
|
|
154
|
+
const normalizedKind = kind.trim() || "*";
|
|
155
|
+
const registry = getOrCreateInboundHandlerRegistry();
|
|
156
|
+
const list = registry.handlers.get(normalizedKind) ?? [];
|
|
157
|
+
list.push(handler);
|
|
158
|
+
registry.handlers.set(normalizedKind, list);
|
|
159
|
+
return () => {
|
|
160
|
+
const updated = registry.handlers.get(normalizedKind) ?? [];
|
|
161
|
+
const index = updated.indexOf(handler);
|
|
162
|
+
if (index !== -1) {
|
|
163
|
+
updated.splice(index, 1);
|
|
164
|
+
registry.handlers.set(normalizedKind, updated);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getTelegramInboundProgrammaticHandlers(
|
|
170
|
+
kind: string,
|
|
171
|
+
): TelegramInboundProgrammaticHandler[] {
|
|
172
|
+
const registry = getOrCreateInboundHandlerRegistry();
|
|
173
|
+
return [
|
|
174
|
+
...(registry.handlers.get(kind) ?? []),
|
|
175
|
+
...(kind === "*" ? [] : (registry.handlers.get("*") ?? [])),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function hasTelegramInboundHandler(kind?: string): boolean {
|
|
180
|
+
const registry = getOrCreateInboundHandlerRegistry();
|
|
181
|
+
if (kind !== undefined) return (registry.handlers.get(kind)?.length ?? 0) > 0;
|
|
182
|
+
return Array.from(registry.handlers.values()).some((list) => list.length > 0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function clearTelegramInboundHandlers(): void {
|
|
186
|
+
getOrCreateInboundHandlerRegistry().handlers.clear();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeInboundProgrammaticHandlerText(
|
|
190
|
+
result: TelegramInboundProgrammaticHandlerResult,
|
|
191
|
+
): string | undefined {
|
|
192
|
+
const text = typeof result === "string" ? result : result?.text;
|
|
193
|
+
const normalized = text?.trim();
|
|
194
|
+
return normalized || undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
106
197
|
function normalizeStringList(value: string | string[] | undefined): string[] {
|
|
107
198
|
if (Array.isArray(value)) {
|
|
108
199
|
return value
|
|
@@ -467,9 +558,84 @@ async function processTelegramTextHandlers(options: {
|
|
|
467
558
|
});
|
|
468
559
|
}
|
|
469
560
|
}
|
|
561
|
+
for (const handler of getTelegramInboundProgrammaticHandlers("text")) {
|
|
562
|
+
try {
|
|
563
|
+
const output = normalizeInboundProgrammaticHandlerText(
|
|
564
|
+
await handler(
|
|
565
|
+
{ kind: "text", text, mimeType: "text/plain" },
|
|
566
|
+
{ cwd: options.cwd },
|
|
567
|
+
),
|
|
568
|
+
);
|
|
569
|
+
if (output) text = output;
|
|
570
|
+
} catch (error) {
|
|
571
|
+
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
|
|
572
|
+
kind: "text",
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
470
576
|
return text;
|
|
471
577
|
}
|
|
472
578
|
|
|
579
|
+
function isTelegramVoiceLikeFile(file: TelegramInboundHandlerFile): boolean {
|
|
580
|
+
return (
|
|
581
|
+
file.kind === "voice" ||
|
|
582
|
+
file.kind === "audio" ||
|
|
583
|
+
matchesWildcard("audio/*", file.mimeType)
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
async function processTelegramFileWithProgrammaticHandlers(
|
|
588
|
+
file: TelegramInboundHandlerFile,
|
|
589
|
+
options: {
|
|
590
|
+
cwd: string;
|
|
591
|
+
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
|
|
592
|
+
},
|
|
593
|
+
): Promise<string | undefined> {
|
|
594
|
+
const kind = file.kind || "*";
|
|
595
|
+
for (const handler of getTelegramInboundProgrammaticHandlers(kind)) {
|
|
596
|
+
try {
|
|
597
|
+
const output = normalizeInboundProgrammaticHandlerText(
|
|
598
|
+
await handler(
|
|
599
|
+
{
|
|
600
|
+
kind,
|
|
601
|
+
file,
|
|
602
|
+
mimeType: file.mimeType,
|
|
603
|
+
},
|
|
604
|
+
{ cwd: options.cwd },
|
|
605
|
+
),
|
|
606
|
+
);
|
|
607
|
+
if (output) return output;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
|
|
610
|
+
fileName: file.fileName || basename(file.path),
|
|
611
|
+
kind,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function transcribeTelegramVoiceFileWithProviders(
|
|
619
|
+
file: TelegramInboundHandlerFile,
|
|
620
|
+
options: {
|
|
621
|
+
recordRuntimeEvent?: TelegramInboundHandlerRuntimeDeps<unknown>["recordRuntimeEvent"];
|
|
622
|
+
},
|
|
623
|
+
): Promise<string | undefined> {
|
|
624
|
+
if (!isTelegramVoiceLikeFile(file)) return undefined;
|
|
625
|
+
for (const provider of getTelegramVoiceTranscriptionProviders()) {
|
|
626
|
+
try {
|
|
627
|
+
const result = await provider(file, {});
|
|
628
|
+
const text = typeof result === "string" ? result : result?.text;
|
|
629
|
+
if (text?.trim()) return text.trim();
|
|
630
|
+
} catch (error) {
|
|
631
|
+
options.recordRuntimeEvent?.("voice-transcription-provider", error, {
|
|
632
|
+
fileName: file.fileName || basename(file.path),
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
|
|
473
639
|
async function readBuiltInTelegramTextAttachment(
|
|
474
640
|
file: TelegramInboundHandlerFile,
|
|
475
641
|
): Promise<string | undefined> {
|
|
@@ -564,6 +730,37 @@ export async function processTelegramInboundHandlers<
|
|
|
564
730
|
});
|
|
565
731
|
}
|
|
566
732
|
}
|
|
733
|
+
if (!hasOutput) {
|
|
734
|
+
try {
|
|
735
|
+
const output = await processTelegramFileWithProgrammaticHandlers(file, {
|
|
736
|
+
cwd: options.cwd,
|
|
737
|
+
recordRuntimeEvent: options.recordRuntimeEvent,
|
|
738
|
+
});
|
|
739
|
+
if (output) {
|
|
740
|
+
outputs.push({ file, output, handler: { type: "programmatic" } });
|
|
741
|
+
hasOutput = true;
|
|
742
|
+
}
|
|
743
|
+
} catch (error) {
|
|
744
|
+
options.recordRuntimeEvent?.("inbound-programmatic-handler", error, {
|
|
745
|
+
fileName: file.fileName || basename(file.path),
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (!hasOutput) {
|
|
750
|
+
try {
|
|
751
|
+
const output = await transcribeTelegramVoiceFileWithProviders(file, {
|
|
752
|
+
recordRuntimeEvent: options.recordRuntimeEvent,
|
|
753
|
+
});
|
|
754
|
+
if (output) {
|
|
755
|
+
outputs.push({ file, output, handler: { type: "voice-provider" } });
|
|
756
|
+
hasOutput = true;
|
|
757
|
+
}
|
|
758
|
+
} catch (error) {
|
|
759
|
+
options.recordRuntimeEvent?.("voice-transcription-provider", error, {
|
|
760
|
+
fileName: file.fileName || basename(file.path),
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
}
|
|
567
764
|
if (!hasOutput) {
|
|
568
765
|
try {
|
|
569
766
|
const output = await readBuiltInTelegramTextAttachment(file);
|
package/lib/media.ts
CHANGED
|
@@ -463,6 +463,7 @@ export function collectTelegramFileInfos(
|
|
|
463
463
|
isImage: false,
|
|
464
464
|
});
|
|
465
465
|
}
|
|
466
|
+
// Generic audio files (e.g. MP3 uploads) — can also trigger voice replies in "mirror" mode
|
|
466
467
|
if (message.audio) {
|
|
467
468
|
const fileName =
|
|
468
469
|
message.audio.file_name ||
|
|
@@ -478,6 +479,8 @@ export function collectTelegramFileInfos(
|
|
|
478
479
|
isImage: false,
|
|
479
480
|
});
|
|
480
481
|
}
|
|
482
|
+
|
|
483
|
+
// Voice messages (recorded via microphone) — primary trigger for "mirror" voice reply mode
|
|
481
484
|
if (message.voice) {
|
|
482
485
|
files.push({
|
|
483
486
|
file_id: message.voice.file_id,
|
package/lib/menu-settings.ts
CHANGED
|
@@ -11,15 +11,21 @@ import {
|
|
|
11
11
|
import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
|
|
12
12
|
import type { TelegramModelMenuState } from "./menu-model.ts";
|
|
13
13
|
import type { MenuModel } from "./model.ts";
|
|
14
|
+
import type { TelegramVoiceReplyMode } from "./voice.ts";
|
|
14
15
|
|
|
15
16
|
export type TelegramSettingsMenuReplyMarkup = TelegramInlineKeyboardMarkup;
|
|
16
17
|
|
|
17
18
|
export interface TelegramSettingsStateDeps {
|
|
18
19
|
isProactivePushEnabled: () => boolean;
|
|
20
|
+
getVoiceReplyMode: () => TelegramVoiceReplyMode;
|
|
21
|
+
isVoiceReplyModeConfigured: () => boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export interface TelegramSettingsMutationDeps extends TelegramSettingsStateDeps {
|
|
22
25
|
setProactivePushEnabled: (enabled: boolean) => Promise<void>;
|
|
26
|
+
setVoiceReplyMode: (
|
|
27
|
+
mode: TelegramVoiceReplyMode | undefined,
|
|
28
|
+
) => Promise<void>;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export interface TelegramSettingsMenuOpenDeps<
|
|
@@ -106,6 +112,20 @@ export interface TelegramSettingsMenuRuntimeDeps<
|
|
|
106
112
|
|
|
107
113
|
export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
|
|
108
114
|
export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>Proactive push:</b>";
|
|
115
|
+
export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>Voice reply mode:</b>";
|
|
116
|
+
|
|
117
|
+
type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
|
|
118
|
+
|
|
119
|
+
function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
|
|
120
|
+
return mode;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getVoiceReplyModeSetting(
|
|
124
|
+
mode: TelegramVoiceReplyMode,
|
|
125
|
+
configured: boolean,
|
|
126
|
+
): TelegramVoiceReplyModeSetting {
|
|
127
|
+
return configured ? mode : "hidden";
|
|
128
|
+
}
|
|
109
129
|
|
|
110
130
|
export function buildTelegramSettingsMenuText(): string {
|
|
111
131
|
return SETTINGS_MENU_TITLE;
|
|
@@ -119,9 +139,24 @@ export function buildProactivePushSettingsText(): string {
|
|
|
119
139
|
].join("\n");
|
|
120
140
|
}
|
|
121
141
|
|
|
142
|
+
export function buildVoiceReplyModeSettingsText(): string {
|
|
143
|
+
return [
|
|
144
|
+
VOICE_REPLY_MODE_SETTINGS_TITLE,
|
|
145
|
+
"",
|
|
146
|
+
"Controls when pi-telegram converts assistant text replies into Telegram voice messages.",
|
|
147
|
+
"",
|
|
148
|
+
"<code>-</code> <code>hidden</code> (default): same behavior as 'manual', but no voice policy is added to prompt context.",
|
|
149
|
+
"<code>-</code> <code>manual</code>: agent decides; explicit 'telegram_voice' markup still works and reply mode is visible in prompt context.",
|
|
150
|
+
"<code>-</code> <code>mirror</code>: voice input prefers a voice reply; text input gracefully follows 'manual' behavior.",
|
|
151
|
+
"<code>-</code> <code>always</code>: every reply is converted to voice when delivery succeeds.",
|
|
152
|
+
].join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
122
155
|
export function buildTelegramSettingsMenuReplyMarkup(
|
|
123
156
|
proactivePushEnabled: boolean,
|
|
157
|
+
voiceReplyMode: TelegramVoiceReplyMode,
|
|
124
158
|
sectionRegistry?: TelegramSectionRegistry,
|
|
159
|
+
voiceReplyModeConfigured = true,
|
|
125
160
|
): TelegramSettingsMenuReplyMarkup {
|
|
126
161
|
const rows: Array<Array<{ text: string; callback_data: string }>> = [
|
|
127
162
|
[{ text: "⬆️ Main menu", callback_data: "menu:back" }],
|
|
@@ -133,12 +168,22 @@ export function buildTelegramSettingsMenuReplyMarkup(
|
|
|
133
168
|
rows.push([{ text: row.label, callback_data: row.callback_data }]);
|
|
134
169
|
}
|
|
135
170
|
}
|
|
136
|
-
rows.push(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
171
|
+
rows.push(
|
|
172
|
+
[
|
|
173
|
+
{
|
|
174
|
+
text: `👄 Voice reply: ${getVoiceReplyModeLabel(
|
|
175
|
+
getVoiceReplyModeSetting(voiceReplyMode, voiceReplyModeConfigured),
|
|
176
|
+
)}`,
|
|
177
|
+
callback_data: "settings:open:voice-reply",
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
[
|
|
181
|
+
{
|
|
182
|
+
text: `${proactivePushEnabled ? "🟢" : "⚫️"} Proactive push`,
|
|
183
|
+
callback_data: "settings:open:proactive",
|
|
184
|
+
},
|
|
185
|
+
],
|
|
186
|
+
);
|
|
142
187
|
return { inline_keyboard: rows };
|
|
143
188
|
}
|
|
144
189
|
|
|
@@ -154,7 +199,9 @@ export async function openTelegramSettingsMenu<
|
|
|
154
199
|
buildTelegramSettingsMenuText(),
|
|
155
200
|
buildTelegramSettingsMenuReplyMarkup(
|
|
156
201
|
deps.isProactivePushEnabled(),
|
|
202
|
+
deps.getVoiceReplyMode(),
|
|
157
203
|
sectionRegistry,
|
|
204
|
+
deps.isVoiceReplyModeConfigured(),
|
|
158
205
|
),
|
|
159
206
|
);
|
|
160
207
|
if (messageId === undefined) return;
|
|
@@ -171,11 +218,11 @@ export function buildProactivePushSettingsReplyMarkup(
|
|
|
171
218
|
[{ text: "⬆️ Back", callback_data: "settings:list" }],
|
|
172
219
|
[
|
|
173
220
|
{
|
|
174
|
-
text: proactivePushEnabled ? "🟢
|
|
221
|
+
text: proactivePushEnabled ? "🟢 on" : "⚫️ on",
|
|
175
222
|
callback_data: "settings:set:proactive:on",
|
|
176
223
|
},
|
|
177
224
|
{
|
|
178
|
-
text: proactivePushEnabled ? "⚫️
|
|
225
|
+
text: proactivePushEnabled ? "⚫️ off" : "🟡 off",
|
|
179
226
|
callback_data: "settings:set:proactive:off",
|
|
180
227
|
},
|
|
181
228
|
],
|
|
@@ -183,6 +230,30 @@ export function buildProactivePushSettingsReplyMarkup(
|
|
|
183
230
|
};
|
|
184
231
|
}
|
|
185
232
|
|
|
233
|
+
export function buildVoiceReplyModeSettingsReplyMarkup(
|
|
234
|
+
mode: TelegramVoiceReplyMode,
|
|
235
|
+
configured = true,
|
|
236
|
+
): TelegramSettingsMenuReplyMarkup {
|
|
237
|
+
const activeMode = getVoiceReplyModeSetting(mode, configured);
|
|
238
|
+
const modes: TelegramVoiceReplyModeSetting[] = [
|
|
239
|
+
"hidden",
|
|
240
|
+
"manual",
|
|
241
|
+
"mirror",
|
|
242
|
+
"always",
|
|
243
|
+
];
|
|
244
|
+
return {
|
|
245
|
+
inline_keyboard: [
|
|
246
|
+
[{ text: "⬆️ Back", callback_data: "settings:list" }],
|
|
247
|
+
...modes.map((value) => [
|
|
248
|
+
{
|
|
249
|
+
text: `${value === activeMode ? "🟢 " : ""}${getVoiceReplyModeLabel(value)}`,
|
|
250
|
+
callback_data: `settings:set:voice-reply:${value}`,
|
|
251
|
+
},
|
|
252
|
+
]),
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
186
257
|
export async function updateTelegramSettingsMenuMessage(
|
|
187
258
|
deps: TelegramSettingsMenuMessageUpdateDeps,
|
|
188
259
|
sectionRegistry?: TelegramSectionRegistry,
|
|
@@ -191,7 +262,9 @@ export async function updateTelegramSettingsMenuMessage(
|
|
|
191
262
|
buildTelegramSettingsMenuText(),
|
|
192
263
|
buildTelegramSettingsMenuReplyMarkup(
|
|
193
264
|
deps.isProactivePushEnabled(),
|
|
265
|
+
deps.getVoiceReplyMode(),
|
|
194
266
|
sectionRegistry,
|
|
267
|
+
deps.isVoiceReplyModeConfigured(),
|
|
195
268
|
),
|
|
196
269
|
);
|
|
197
270
|
}
|
|
@@ -205,6 +278,18 @@ export async function updateProactivePushSettingsMessage(
|
|
|
205
278
|
);
|
|
206
279
|
}
|
|
207
280
|
|
|
281
|
+
export async function updateVoiceReplyModeSettingsMessage(
|
|
282
|
+
deps: TelegramSettingsMenuCallbackDeps,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
await deps.updateSettingsMessage(
|
|
285
|
+
buildVoiceReplyModeSettingsText(),
|
|
286
|
+
buildVoiceReplyModeSettingsReplyMarkup(
|
|
287
|
+
deps.getVoiceReplyMode(),
|
|
288
|
+
deps.isVoiceReplyModeConfigured(),
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
208
293
|
export async function handleTelegramSettingsMenuCallbackAction(
|
|
209
294
|
callbackQueryId: string,
|
|
210
295
|
data: string | undefined,
|
|
@@ -221,6 +306,28 @@ export async function handleTelegramSettingsMenuCallbackAction(
|
|
|
221
306
|
await deps.answerCallbackQuery(callbackQueryId);
|
|
222
307
|
return true;
|
|
223
308
|
}
|
|
309
|
+
if (data === "settings:open:voice-reply") {
|
|
310
|
+
await updateVoiceReplyModeSettingsMessage(deps);
|
|
311
|
+
await deps.answerCallbackQuery(callbackQueryId);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
if (data.startsWith("settings:set:voice-reply:")) {
|
|
315
|
+
const mode = data.slice("settings:set:voice-reply:".length);
|
|
316
|
+
if (
|
|
317
|
+
mode === "hidden" ||
|
|
318
|
+
mode === "manual" ||
|
|
319
|
+
mode === "mirror" ||
|
|
320
|
+
mode === "always"
|
|
321
|
+
) {
|
|
322
|
+
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
|
323
|
+
await updateVoiceReplyModeSettingsMessage(deps);
|
|
324
|
+
await deps.answerCallbackQuery(
|
|
325
|
+
callbackQueryId,
|
|
326
|
+
`Voice reply mode: ${mode}`,
|
|
327
|
+
);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
224
331
|
if (
|
|
225
332
|
data === "settings:set:proactive:on" ||
|
|
226
333
|
data === "settings:set:proactive:off"
|
|
@@ -251,6 +358,8 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
251
358
|
{
|
|
252
359
|
getModelMenuState: () => deps.getModelMenuState(chatId, ctx),
|
|
253
360
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
|
361
|
+
getVoiceReplyMode: deps.getVoiceReplyMode,
|
|
362
|
+
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
|
|
254
363
|
sendSettingsMenu: (state, text, replyMarkup) =>
|
|
255
364
|
deps.sendInteractiveMessage(
|
|
256
365
|
state.chatId,
|
|
@@ -266,6 +375,8 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
266
375
|
updateTelegramSettingsMenuMessage(
|
|
267
376
|
{
|
|
268
377
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
|
378
|
+
getVoiceReplyMode: deps.getVoiceReplyMode,
|
|
379
|
+
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
|
|
269
380
|
updateSettingsMessage: (text, replyMarkup) =>
|
|
270
381
|
deps.editInteractiveMessage(
|
|
271
382
|
state.chatId,
|
|
@@ -281,6 +392,18 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
281
392
|
if (!query.data?.startsWith("settings:")) return false;
|
|
282
393
|
const state = deps.getStoredModelMenuState(query.message?.message_id);
|
|
283
394
|
if (!state) {
|
|
395
|
+
const mode = query.data.slice("settings:set:voice-reply:".length);
|
|
396
|
+
if (
|
|
397
|
+
query.data.startsWith("settings:set:voice-reply:") &&
|
|
398
|
+
(mode === "hidden" ||
|
|
399
|
+
mode === "manual" ||
|
|
400
|
+
mode === "mirror" ||
|
|
401
|
+
mode === "always")
|
|
402
|
+
) {
|
|
403
|
+
await deps.setVoiceReplyMode(mode === "hidden" ? undefined : mode);
|
|
404
|
+
await deps.answerCallbackQuery(query.id, `Voice reply mode: ${mode}`);
|
|
405
|
+
return true;
|
|
406
|
+
}
|
|
284
407
|
await deps.answerCallbackQuery(
|
|
285
408
|
query.id,
|
|
286
409
|
"Interactive message expired.",
|
|
@@ -289,7 +412,10 @@ export function createTelegramSettingsMenuRuntime<
|
|
|
289
412
|
}
|
|
290
413
|
return handleTelegramSettingsMenuCallbackAction(query.id, query.data, {
|
|
291
414
|
isProactivePushEnabled: deps.isProactivePushEnabled,
|
|
415
|
+
getVoiceReplyMode: deps.getVoiceReplyMode,
|
|
416
|
+
isVoiceReplyModeConfigured: deps.isVoiceReplyModeConfigured,
|
|
292
417
|
setProactivePushEnabled: deps.setProactivePushEnabled,
|
|
418
|
+
setVoiceReplyMode: deps.setVoiceReplyMode,
|
|
293
419
|
updateSettingsMessage: (text, replyMarkup) =>
|
|
294
420
|
deps.editInteractiveMessage(
|
|
295
421
|
state.chatId,
|