@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.
@@ -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
- const GLOBAL_SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__" as const;
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[GLOBAL_SECTION_REGISTRY_KEY] = registry;
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[GLOBAL_SECTION_REGISTRY_KEY];
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[GLOBAL_SECTION_REGISTRY_KEY];
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
- export function getTelegramSectionMainMenuRows(
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: (
@@ -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 directly
159
- * via `globalThis.__piTelegramExternalHandlerRegistry__` (versioned object,
160
- * see {@link TelegramExternalHandlerRegistry}). This avoids importing
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,
@@ -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,
@@ -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
- text: `${proactivePushEnabled ? "🟢" : "⚫️"} Proactive push`,
139
- callback_data: "settings:open:proactive",
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 ? "🟢 On" : "⚫️ On",
221
+ text: proactivePushEnabled ? "🟢 on" : "⚫️ on",
175
222
  callback_data: "settings:set:proactive:on",
176
223
  },
177
224
  {
178
- text: proactivePushEnabled ? "⚫️ Off" : "🟡 Off",
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,