@llblab/pi-telegram 0.11.2 → 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.
Files changed (53) hide show
  1. package/AGENTS.md +20 -15
  2. package/BACKLOG.md +1 -11
  3. package/CHANGELOG.md +41 -1
  4. package/README.md +15 -41
  5. package/api/inbound.ts +14 -0
  6. package/api/keyboard.ts +10 -0
  7. package/api/outbound.ts +11 -0
  8. package/api/sections.ts +17 -0
  9. package/api/updates.ts +11 -0
  10. package/api/voice.ts +24 -0
  11. package/docs/README.md +7 -5
  12. package/docs/architecture.md +162 -226
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +18 -16
  15. package/docs/{inbound-handlers.md → inbound.md} +14 -11
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +14 -11
  18. package/docs/public-api.md +420 -0
  19. package/docs/{extension-sections.md → sections.md} +34 -30
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +27 -19
  23. package/index.ts +88 -242
  24. package/lib/bindings.ts +299 -0
  25. package/lib/command-templates.ts +249 -60
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +44 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +31 -21
  29. package/lib/lifecycle.ts +41 -6
  30. package/lib/locks.ts +4 -1
  31. package/lib/menu-model.ts +3 -3
  32. package/lib/menu-queue.ts +1 -1
  33. package/lib/menu-settings.ts +21 -10
  34. package/lib/menu-status.ts +1 -1
  35. package/lib/menu.ts +1 -1
  36. package/lib/outbound-buttons.ts +226 -0
  37. package/lib/outbound-markup.ts +357 -0
  38. package/lib/outbound-voice.ts +263 -0
  39. package/lib/outbound.ts +908 -0
  40. package/lib/polling.ts +4 -3
  41. package/lib/preview.ts +2 -2
  42. package/lib/queue.ts +3 -0
  43. package/lib/replies.ts +4 -1
  44. package/lib/routing.ts +44 -3
  45. package/lib/{extension-sections.ts → sections.ts} +37 -8
  46. package/lib/status.ts +13 -0
  47. package/lib/{api.ts → telegram-api.ts} +4 -4
  48. package/lib/text-groups.ts +3 -2
  49. package/lib/updates.ts +121 -1
  50. package/lib/voice.ts +67 -21
  51. package/package.json +13 -3
  52. package/lib/external-handlers.ts +0 -166
  53. package/lib/outbound-handlers.ts +0 -1663
package/lib/polling.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Telegram polling domain helpers
2
+ * Telegram polling runtime domain helpers
3
3
  * Zones: telegram transport, polling runtime
4
4
  * Owns polling request builders, stop conditions, and the long-poll loop runtime for Telegram updates
5
5
  */
@@ -91,8 +91,9 @@ export function createTelegramPollingActivityReader(
91
91
  return () => isTelegramPollingControllerActive(state);
92
92
  }
93
93
 
94
- export interface TelegramPollingRuntimeDeps<TContext>
95
- extends TelegramRuntimeEventRecorderPort {
94
+ export interface TelegramPollingRuntimeDeps<
95
+ TContext,
96
+ > extends TelegramRuntimeEventRecorderPort {
96
97
  hasBotToken: () => boolean;
97
98
  getPollingPromise: () => Promise<void> | undefined;
98
99
  setPollingPromise: (promise: Promise<void> | undefined) => void;
package/lib/preview.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  TelegramReplyParameters,
10
10
  TelegramSendMessageBody,
11
11
  TelegramSentMessage,
12
- } from "./api.ts";
12
+ } from "./telegram-api.ts";
13
13
  import {
14
14
  buildTelegramPreviewSnapshot,
15
15
  MAX_MESSAGE_LENGTH,
@@ -22,7 +22,7 @@ import {
22
22
  } from "./rendering.ts";
23
23
 
24
24
  import { buildTelegramReplyParameters } from "./replies.ts";
25
- import { stripTelegramCommentMarkupForPreview } from "./outbound-handlers.ts";
25
+ import { stripTelegramCommentMarkupForPreview } from "./outbound.ts";
26
26
  import { shouldSuppressPreviewForVoice } from "./voice.ts";
27
27
 
28
28
  const TELEGRAM_PREVIEW_THROTTLE_MS = 750;
package/lib/queue.ts CHANGED
@@ -44,6 +44,9 @@ export interface TelegramQueueLaneContract {
44
44
 
45
45
  export const TELEGRAM_QUEUE_LANE_CONTRACTS: readonly TelegramQueueLaneContract[] =
46
46
  [
47
+ // Control lane intentionally accepts both direct controls and resume prompts.
48
+ // Model-switch continuations need prompt semantics but must run before queued user work.
49
+ // Do not admit ordinary user prompts here without an explicit control-flow reason.
47
50
  {
48
51
  lane: "control",
49
52
  admissionMode: "control-queue",
package/lib/replies.ts CHANGED
@@ -4,7 +4,10 @@
4
4
  * Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
5
5
  */
6
6
 
7
- import type { TelegramReplyParameters, TelegramSentMessage } from "./api.ts";
7
+ import type {
8
+ TelegramReplyParameters,
9
+ TelegramSentMessage,
10
+ } from "./telegram-api.ts";
8
11
  import {
9
12
  renderTelegramMessage,
10
13
  type TelegramRenderedChunk,
package/lib/routing.ts CHANGED
@@ -7,12 +7,12 @@
7
7
  import { readFile } from "node:fs/promises";
8
8
  import * as Commands from "./commands.ts";
9
9
  import type { TelegramConfigStore } from "./config.ts";
10
- import type { TelegramSectionRegistry } from "./extension-sections.ts";
11
- import type { TelegramInboundHandlerRuntime } from "./inbound-handlers.ts";
10
+ import type { TelegramSectionRegistry } from "./sections.ts";
11
+ import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
12
12
  import * as Media from "./media.ts";
13
13
  import * as Menu from "./menu.ts";
14
14
  import * as Model from "./model.ts";
15
- import * as OutboundHandlers from "./outbound-handlers.ts";
15
+ import * as OutboundHandlers from "./outbound.ts";
16
16
  import * as PromptTemplates from "./prompt-templates.ts";
17
17
  import * as Queue from "./queue.ts";
18
18
  import type { TelegramBridgeRuntime } from "./runtime.ts";
@@ -138,6 +138,7 @@ export interface TelegramInboundRouteRuntimeDeps<
138
138
  }
139
139
 
140
140
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
141
+ "compact:",
141
142
  "menu:",
142
143
  "model:",
143
144
  "queue:",
@@ -262,6 +263,45 @@ export function createTelegramInboundRouteRuntime<
262
263
  );
263
264
  if (handled) return;
264
265
  }
266
+ const handledByCompact =
267
+ await Commands.handleTelegramCompactConfirmationCallback(query, {
268
+ ctx,
269
+ answerCallbackQuery: deps.answerCallbackQuery,
270
+ editInteractiveMessage: deps.editInteractiveMessage ?? (async () => {}),
271
+ runCompact: async (compactCtx, chatId, replyToMessageId) => {
272
+ await Commands.handleTelegramCompactCommand({
273
+ isIdle: () => deps.isIdle(compactCtx),
274
+ hasPendingMessages: () => deps.hasPendingMessages(compactCtx),
275
+ hasActiveTelegramTurn: deps.activeTurnRuntime.has,
276
+ hasDispatchPending: deps.bridgeRuntime.lifecycle.hasDispatchPending,
277
+ hasQueuedTelegramItems: deps.telegramQueueStore.hasQueuedItems,
278
+ isCompactionInProgress:
279
+ deps.bridgeRuntime.lifecycle.isCompactionInProgress,
280
+ setCompactionInProgress:
281
+ deps.bridgeRuntime.lifecycle.setCompactionInProgress,
282
+ updateStatus: () => deps.updateStatus(compactCtx),
283
+ dispatchNextQueuedTelegramTurn: () =>
284
+ deps.dispatchNextQueuedTelegramTurn(compactCtx),
285
+ requestDeferredDispatchNextQueuedTelegramTurn:
286
+ deps.requestDeferredDispatchNextQueuedTelegramTurn
287
+ ? (dispatch) =>
288
+ deps.requestDeferredDispatchNextQueuedTelegramTurn?.(() =>
289
+ dispatch(),
290
+ )
291
+ : undefined,
292
+ compact: (callbacks) => deps.compact(compactCtx, callbacks),
293
+ startTypingLoop: deps.startTypingLoop
294
+ ? () => deps.startTypingLoop?.(compactCtx, chatId)
295
+ : undefined,
296
+ stopTypingLoop: deps.stopTypingLoop,
297
+ sendTextReply: (text) =>
298
+ deps.sendTextReply(chatId, replyToMessageId, text).then(() => {}),
299
+ suppressStartNotice: true,
300
+ recordRuntimeEvent: deps.recordRuntimeEvent,
301
+ });
302
+ },
303
+ });
304
+ if (handledByCompact) return;
265
305
  const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx);
266
306
  if (handledByQueue) return;
267
307
  const handledBySettings = await deps.settingsMenuCallbackHandler?.(
@@ -372,6 +412,7 @@ export function createTelegramInboundRouteRuntime<
372
412
  getPromptTemplateCommands,
373
413
  persistConfig: deps.configStore.persist,
374
414
  sendTextReply: deps.sendTextReply,
415
+ sendInteractiveMessage: deps.sendInteractiveMessage,
375
416
  recordRuntimeEvent: deps.recordRuntimeEvent,
376
417
  });
377
418
  const promptEnqueue = Queue.createTelegramPromptEnqueueController<
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Telegram Extension Sections registry and callback routing
3
3
  * Zones: telegram ui, extension platform, callback routing
4
- * Owns section registration, token mapping, main-menu/settings row injection, and section callback dispatch
4
+ * Owns section registration, global registry binding, token mapping, main-menu/settings row injection, and section callback dispatch
5
5
  */
6
6
 
7
7
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
8
8
 
9
9
  const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
10
+ const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
10
11
 
11
12
  // --- Core Types ---
12
13
 
@@ -181,9 +182,7 @@ function buildTelegramSectionContext(
181
182
  .then(() => {}),
182
183
  enqueuePrompt: deps.enqueuePrompt,
183
184
  callbackData: (action, payload) =>
184
- payload
185
- ? `section:${token}:${action}:${payload}`
186
- : `section:${token}:${action}`,
185
+ buildTelegramSectionCallbackData(token, action, payload),
187
186
  deleteMessage: () =>
188
187
  messageId !== undefined
189
188
  ? deps.deleteMessage(chatId, messageId)
@@ -231,9 +230,7 @@ function buildTelegramSectionCallbackContext(
231
230
  .then(() => {}),
232
231
  enqueuePrompt: deps.enqueuePrompt,
233
232
  callbackData: (action, payload) =>
234
- payload
235
- ? `section:${token}:${action}:${payload}`
236
- : `section:${token}:${action}`,
233
+ buildTelegramSectionCallbackData(token, action, payload),
237
234
  deleteMessage: () =>
238
235
  messageId !== undefined
239
236
  ? deps.deleteMessage(chatId, messageId)
@@ -250,6 +247,13 @@ export function setGlobalTelegramSectionRegistry(
250
247
  (globalThis as Record<string, unknown>)[SECTION_REGISTRY_KEY] = registry;
251
248
  }
252
249
 
250
+ /** @internal */
251
+ export function createAndBindTelegramSectionRegistry(): TelegramSectionRegistry {
252
+ const registry = createTelegramExtensionSectionRegistry();
253
+ setGlobalTelegramSectionRegistry(registry);
254
+ return registry;
255
+ }
256
+
253
257
  /**
254
258
  * Register a Telegram Extension Section from any pi extension.
255
259
  * Returns a disposer. Throws if no section registry is active.
@@ -271,8 +275,8 @@ export function registerTelegramSection(
271
275
 
272
276
  /**
273
277
  * Get current section diagnostics. Returns empty array when registry is absent.
278
+ * @internal
274
279
  */
275
- /** @internal */
276
280
  export function getTelegramSectionDiagnostics(): TelegramSectionDiagnostic[] {
277
281
  const registry = (globalThis as Record<string, unknown>)[
278
282
  SECTION_REGISTRY_KEY
@@ -291,6 +295,27 @@ const BACK_NAV_ROW = {
291
295
  text: "⬆️ Back",
292
296
  } as const;
293
297
 
298
+ function getUtf8ByteLength(value: string): number {
299
+ return new TextEncoder().encode(value).byteLength;
300
+ }
301
+
302
+ function buildTelegramSectionCallbackData(
303
+ token: TelegramSectionToken,
304
+ action: string,
305
+ payload?: string,
306
+ ): string {
307
+ const data = payload
308
+ ? `section:${token}:${action}:${payload}`
309
+ : `section:${token}:${action}`;
310
+ const byteLength = getUtf8ByteLength(data);
311
+ if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
312
+ throw new Error(
313
+ `Telegram section callback_data exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
314
+ );
315
+ }
316
+ return data;
317
+ }
318
+
294
319
  function prependBackRow(
295
320
  replyMarkup: TelegramInlineKeyboardMarkup | undefined,
296
321
  backCallback: string,
@@ -319,6 +344,10 @@ export function createTelegramExtensionSectionRegistry(): TelegramSectionRegistr
319
344
  let nextToken = 0;
320
345
 
321
346
  function register(section: TelegramSectionRegistration): () => void {
347
+ const duplicate = [...sections.values()].find((s) => s.id === section.id);
348
+ if (duplicate) {
349
+ throw new Error(`Telegram section id already registered: ${section.id}`);
350
+ }
322
351
  const token = String(nextToken++);
323
352
  const registered: RegisteredTelegramSection = {
324
353
  id: section.id,
package/lib/status.ts CHANGED
@@ -289,12 +289,25 @@ function formatTelegramRuntimeEvent(event: TelegramRuntimeEvent): string {
289
289
  return `${new Date(event.at).toISOString()} ${formatTelegramRuntimeEventSummary(event)}`;
290
290
  }
291
291
 
292
+ function buildTelegramRuntimeEventSummary(events: TelegramRuntimeEvent[]): string {
293
+ const counts = new Map<string, number>();
294
+ for (const event of events) {
295
+ const category = formatTelegramRuntimeEventCategory(event);
296
+ counts.set(category, (counts.get(category) ?? 0) + 1);
297
+ }
298
+ return Array.from(counts.entries())
299
+ .sort(([left], [right]) => left.localeCompare(right))
300
+ .map(([category, count]) => `${category}=${count}`)
301
+ .join(", ");
302
+ }
303
+
292
304
  export function buildTelegramRuntimeEventLines(
293
305
  events: TelegramRuntimeEvent[],
294
306
  ): string[] {
295
307
  if (events.length === 0) return ["recent runtime events: none"];
296
308
  return [
297
309
  "recent runtime events:",
310
+ `- summary: ${buildTelegramRuntimeEventSummary(events)}`,
298
311
  ...events
299
312
  .slice()
300
313
  .reverse()
@@ -416,13 +416,13 @@ async function writeTelegramDownloadResponse(
416
416
  if (!response.body) {
417
417
  const buffer = Buffer.from(await response.arrayBuffer());
418
418
  assertTelegramFileSizeWithinLimit(buffer.byteLength, maxFileSizeBytes);
419
- await writeFile(targetPath, buffer);
419
+ await writeFile(targetPath, buffer, { mode: 0o600 });
420
420
  return;
421
421
  }
422
422
  await pipeline(
423
423
  Readable.from(response.body, { objectMode: false }),
424
424
  createTelegramDownloadLimitTransform(maxFileSizeBytes),
425
- createWriteStream(targetPath),
425
+ createWriteStream(targetPath, { mode: 0o600 }),
426
426
  );
427
427
  }
428
428
 
@@ -536,7 +536,7 @@ export async function prepareTelegramTempDir(
536
536
  tempDir: string,
537
537
  maxAgeMs: number,
538
538
  ): Promise<number> {
539
- await mkdir(tempDir, { recursive: true });
539
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
540
540
  return cleanupTelegramTempFiles(tempDir, maxAgeMs);
541
541
  }
542
542
 
@@ -630,7 +630,7 @@ export async function downloadTelegramFile(
630
630
  { signal: options?.signal },
631
631
  );
632
632
  assertTelegramFileSizeWithinLimit(file.file_size, options?.maxFileSizeBytes);
633
- await mkdir(tempDir, { recursive: true });
633
+ await mkdir(tempDir, { recursive: true, mode: 0o700 });
634
634
  const targetPath = join(
635
635
  tempDir,
636
636
  `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
@@ -6,7 +6,7 @@
6
6
 
7
7
  const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 1000;
8
8
  const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
9
- const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 10;
9
+ const TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP = 12;
10
10
 
11
11
  export interface TelegramTextGroupMessage {
12
12
  message_id: number;
@@ -92,7 +92,8 @@ function canAppendTelegramTextGroupMessage<
92
92
  return (
93
93
  !!previous &&
94
94
  message.message_id > previous.message_id &&
95
- message.message_id <= previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
95
+ message.message_id <=
96
+ previous.message_id + TELEGRAM_TEXT_GROUP_MAX_MESSAGE_ID_GAP &&
96
97
  text.length > 0 &&
97
98
  !isTelegramTextGroupCommand(text)
98
99
  );
package/lib/updates.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Telegram updates domain helpers
3
3
  * Zones: telegram inbound, authorization, routing plans
4
- * Owns update extraction, authorization, classification, execution planning, and runtime execution for Telegram updates
4
+ * Owns update extraction, authorization, classification, execution planning, runtime execution, and the public update-handler registry
5
5
  */
6
6
 
7
7
  import {
@@ -851,3 +851,123 @@ export async function executeTelegramUpdatePlan<
851
851
  if (!isTelegramStaleContextError(error)) throw error;
852
852
  }
853
853
  }
854
+
855
+ // --- Public update handler registry ---
856
+
857
+ /**
858
+ * Verdict returned by a public Telegram update handler.
859
+ *
860
+ * - `"consume"` — the handler processed this update; pi-telegram skips default routing.
861
+ * - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
862
+ */
863
+ export type TelegramUpdateHandlerVerdict = "consume" | "pass";
864
+
865
+ export type TelegramUpdateHandler = (
866
+ update: unknown,
867
+ ) =>
868
+ | TelegramUpdateHandlerVerdict
869
+ | void
870
+ | Promise<TelegramUpdateHandlerVerdict | void>;
871
+
872
+ export interface TelegramUpdateHandlerRegistry {
873
+ /** Schema version of this registry shape. */
874
+ readonly version: 1;
875
+ /**
876
+ * Register an update handler. Returns a disposer that removes it.
877
+ *
878
+ * Handlers are invoked in registration order on every Telegram update,
879
+ * before pi-telegram's own routing. The first handler that returns
880
+ * `"consume"` wins and stops the chain for that update.
881
+ */
882
+ add: (handler: TelegramUpdateHandler) => () => void;
883
+ /**
884
+ * Run all registered handlers against an update.
885
+ *
886
+ * Used by pi-telegram's polling runtime; companion extensions should call
887
+ * {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
888
+ */
889
+ dispatch: (update: unknown) => Promise<TelegramUpdateHandlerVerdict>;
890
+ }
891
+
892
+ const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
893
+
894
+ function isValidV1UpdateHandlerRegistry(
895
+ candidate: unknown,
896
+ ): candidate is TelegramUpdateHandlerRegistry {
897
+ if (!candidate || typeof candidate !== "object") return false;
898
+ const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
899
+ return (
900
+ r.version === 1 &&
901
+ typeof r.add === "function" &&
902
+ typeof r.dispatch === "function"
903
+ );
904
+ }
905
+
906
+ function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
907
+ const g = globalThis as Record<string, unknown>;
908
+ const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
909
+ if (isValidV1UpdateHandlerRegistry(existing)) return existing;
910
+ const handlers = new Set<TelegramUpdateHandler>();
911
+ const registry: TelegramUpdateHandlerRegistry = {
912
+ version: 1,
913
+ add(handler) {
914
+ handlers.add(handler);
915
+ return () => handlers.delete(handler);
916
+ },
917
+ async dispatch(update) {
918
+ for (const handler of handlers) {
919
+ try {
920
+ const result = await handler(update);
921
+ if (result === "consume") return "consume";
922
+ } catch {
923
+ // Update handler errors must not break polling.
924
+ }
925
+ }
926
+ return "pass";
927
+ },
928
+ };
929
+ g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
930
+ return registry;
931
+ }
932
+
933
+ /**
934
+ * Called by pi-telegram's own runtime to obtain the registry it dispatches
935
+ * through. Companion extensions should not call this; use
936
+ * {@link registerTelegramUpdateHandler} instead.
937
+ */
938
+ export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
939
+ return getOrCreateUpdateHandlerRegistry();
940
+ }
941
+
942
+ export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
943
+ defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
944
+ registry?: TelegramUpdateHandlerRegistry;
945
+ }
946
+
947
+ /**
948
+ * Wrap a default polling `handleUpdate` with the public update handler registry.
949
+ */
950
+ export function createTelegramUpdateHandle<TUpdate, TContext>(
951
+ deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
952
+ ): (update: TUpdate, ctx: TContext) => Promise<void> {
953
+ const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
954
+ const { defaultHandle } = deps;
955
+ return async function handleTelegramUpdate(update, ctx) {
956
+ const verdict = await registry.dispatch(update);
957
+ if (verdict === "consume") return;
958
+ await defaultHandle(update, ctx);
959
+ };
960
+ }
961
+
962
+ /**
963
+ * Register a handler that runs before pi-telegram routes a Telegram update
964
+ * through its built-in handlers.
965
+ *
966
+ * This is the low-level public surface for companion extensions that share
967
+ * the same bot and pi process with pi-telegram.
968
+ */
969
+ export function registerTelegramUpdateHandler(
970
+ handler: TelegramUpdateHandler,
971
+ ): () => void {
972
+ return getOrCreateUpdateHandlerRegistry().add(handler);
973
+ }
package/lib/voice.ts CHANGED
@@ -12,16 +12,32 @@
12
12
  *
13
13
  * Separation of concerns:
14
14
  * - All decision logic and domain rules live here.
15
- * - Actual delivery (sending the audio via Telegram) stays in outbound-handlers.ts.
15
+ * - Actual delivery (sending the audio via Telegram) stays in outbound.ts.
16
16
  *
17
17
  * Keeps voice policy, turn tagging, prompt contributions, and markup helpers
18
18
  * out of the queue, preview, turn-building, and delivery domains.
19
19
  */
20
20
 
21
- const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY = "__piTelegramVoiceSynthesisProviders__";
21
+ const VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY =
22
+ "__piTelegramVoiceSynthesisProviders__";
22
23
  const VOICE_TRANSCRIPTION_PROVIDER_REGISTRY_KEY =
23
24
  "__piTelegramVoiceTranscriptionProviders__";
24
25
 
26
+ let nextGeneratedVoiceSynthesisProviderId = 0;
27
+ let nextGeneratedVoiceTranscriptionProviderId = 0;
28
+
29
+ function getNextAvailableProviderId<T>(
30
+ registry: Map<string, T>,
31
+ prefix: string,
32
+ nextId: () => number,
33
+ ): string {
34
+ let id: string;
35
+ do {
36
+ id = `${prefix}-${nextId()}`;
37
+ } while (registry.has(id));
38
+ return id;
39
+ }
40
+
25
41
  export type TelegramVoiceReplyMode = "mirror" | "always" | "manual";
26
42
 
27
43
  export type TelegramVoiceSynthesisProviderResult =
@@ -81,11 +97,20 @@ function getOrCreateVoiceSynthesisProviderRegistry(): Map<
81
97
  if (existing instanceof Map)
82
98
  return existing as Map<string, TelegramVoiceSynthesisProvider>;
83
99
  const registry = new Map<string, TelegramVoiceSynthesisProvider>();
84
- (globalThis as Record<string, unknown>)[VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY] =
85
- registry;
100
+ (globalThis as Record<string, unknown>)[
101
+ VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
102
+ ] = registry;
86
103
  return registry;
87
104
  }
88
105
 
106
+ /**
107
+ * Register a high-level Telegram voice synthesis provider.
108
+ *
109
+ * Stable public API callers must pass a stable `options.id` so diagnostics,
110
+ * replacement, and cleanup can identify the provider. Omitted ids remain a
111
+ * compatibility path for pre-matrix callers and receive generated session-local
112
+ * ids.
113
+ */
89
114
  export function registerTelegramVoiceSynthesisProvider(
90
115
  provider:
91
116
  | TelegramVoiceSynthesisProvider
@@ -96,22 +121,30 @@ export function registerTelegramVoiceSynthesisProvider(
96
121
  options?: { id?: string },
97
122
  ): () => void {
98
123
  const registry = getOrCreateVoiceSynthesisProviderRegistry();
99
- const id = options?.id ?? `voice-synthesis-provider-${registry.size}`;
124
+ const id =
125
+ options?.id ??
126
+ getNextAvailableProviderId(
127
+ registry,
128
+ "voice-synthesis-provider",
129
+ () => nextGeneratedVoiceSynthesisProviderId++,
130
+ );
100
131
  const normalized =
101
132
  typeof provider === "function"
102
133
  ? (Object.assign(
103
134
  (text: string, options?: { lang?: string; rate?: string }) =>
104
135
  provider(text, options),
105
136
  {
106
- getVoicePolicy: (provider as TelegramVoiceSynthesisProvider).getVoicePolicy,
107
- getVoicePromptContribution: (provider as TelegramVoiceSynthesisProvider)
108
- .getVoicePromptContribution,
137
+ getVoicePolicy: (provider as TelegramVoiceSynthesisProvider)
138
+ .getVoicePolicy,
139
+ getVoicePromptContribution: (
140
+ provider as TelegramVoiceSynthesisProvider
141
+ ).getVoicePromptContribution,
109
142
  },
110
143
  ) as TelegramVoiceSynthesisProvider)
111
144
  : provider;
112
145
  registry.set(id, normalized);
113
146
  return () => {
114
- registry.delete(id);
147
+ if (registry.get(id) === normalized) registry.delete(id);
115
148
  };
116
149
  }
117
150
 
@@ -144,15 +177,28 @@ function getOrCreateVoiceTranscriptionProviderRegistry(): Map<
144
177
  return registry;
145
178
  }
146
179
 
180
+ /**
181
+ * Register a high-level Telegram voice transcription provider.
182
+ *
183
+ * Stable public API callers must pass a stable `options.id`. Omitted ids remain
184
+ * a compatibility path for pre-matrix callers and receive generated
185
+ * session-local ids.
186
+ */
147
187
  export function registerTelegramVoiceTranscriptionProvider(
148
188
  provider: TelegramVoiceTranscriptionProvider,
149
189
  options?: { id?: string },
150
190
  ): () => void {
151
191
  const registry = getOrCreateVoiceTranscriptionProviderRegistry();
152
- const id = options?.id ?? `voice-transcription-provider-${registry.size}`;
192
+ const id =
193
+ options?.id ??
194
+ getNextAvailableProviderId(
195
+ registry,
196
+ "voice-transcription-provider",
197
+ () => nextGeneratedVoiceTranscriptionProviderId++,
198
+ );
153
199
  registry.set(id, provider);
154
200
  return () => {
155
- registry.delete(id);
201
+ if (registry.get(id) === provider) registry.delete(id);
156
202
  };
157
203
  }
158
204
 
@@ -182,9 +228,9 @@ export const TELEGRAM_VOICE_REPLY_MODES = [
182
228
  * Pi-telegram owns reply-mode policy through telegram.json. If
183
229
  * config.voice.replyMode is missing or invalid, the safe default is manual.
184
230
  */
185
- export function getTelegramVoiceReplyMode(
186
- config?: { voice?: { replyMode?: string } },
187
- ): TelegramVoiceReplyMode {
231
+ export function getTelegramVoiceReplyMode(config?: {
232
+ voice?: { replyMode?: string };
233
+ }): TelegramVoiceReplyMode {
188
234
  const configMode = config?.voice?.replyMode;
189
235
  if (
190
236
  configMode &&
@@ -202,9 +248,9 @@ export function getTelegramVoiceReplyMode(
202
248
  * Reads from `config.voice.sendTranscript`.
203
249
  * Default: false (no transcript text sent at all).
204
250
  */
205
- export function getTelegramVoiceSendTranscript(
206
- config?: { voice?: { sendTranscript?: boolean } },
207
- ): boolean {
251
+ export function getTelegramVoiceSendTranscript(config?: {
252
+ voice?: { sendTranscript?: boolean };
253
+ }): boolean {
208
254
  return !!config?.voice?.sendTranscript;
209
255
  }
210
256
 
@@ -284,12 +330,12 @@ export function shouldSuppressPreviewForVoice(
284
330
  return !!(turn?.voiceReplyPreferred || turn?.voiceReplyRequired);
285
331
  }
286
332
 
287
- // --- Outbound Handler Re-Exports ---
333
+ // --- Outbound Markup Re-Exports ---
288
334
 
289
335
  export {
336
+ normalizeMarkdownAfterVoiceExtraction,
290
337
  planTelegramVoiceReply,
291
- stripTelegramCommentMarkupForPreview,
292
338
  stripTelegramCommentMarkupForDelivery,
339
+ stripTelegramCommentMarkupForPreview,
293
340
  stripTelegramVoiceMarkupForPreview,
294
- normalizeMarkdownAfterVoiceExtraction,
295
- } from "./outbound-handlers.ts";
341
+ } from "./outbound-markup.ts";
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.11.2",
3
+ "version": "0.13.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
8
  "description": "Telegram runtime adapter for π",
9
- "type": "module",
10
9
  "keywords": [
11
10
  "pi-package",
12
11
  "pi",
@@ -14,6 +13,7 @@
14
13
  "bot",
15
14
  "extension"
16
15
  ],
16
+ "type": "module",
17
17
  "license": "MIT",
18
18
  "repository": {
19
19
  "type": "git",
@@ -24,7 +24,7 @@
24
24
  "url": "https://github.com/llblab/pi-telegram/issues"
25
25
  },
26
26
  "engines": {
27
- "node": ">=22.0.0"
27
+ "node": ">=22.19.0"
28
28
  },
29
29
  "scripts": {
30
30
  "test": "node --experimental-strip-types --test tests/*.test.ts",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "index.ts",
38
+ "api/",
38
39
  "lib/",
39
40
  "README.md",
40
41
  "AGENTS.md",
@@ -43,6 +44,15 @@
43
44
  "docs/",
44
45
  "screenshot.png"
45
46
  ],
47
+ "exports": {
48
+ ".": "./index.ts",
49
+ "./inbound": "./api/inbound.ts",
50
+ "./outbound": "./api/outbound.ts",
51
+ "./updates": "./api/updates.ts",
52
+ "./sections": "./api/sections.ts",
53
+ "./voice": "./api/voice.ts",
54
+ "./keyboard": "./api/keyboard.ts"
55
+ },
46
56
  "pi": {
47
57
  "extensions": [
48
58
  "./index.ts"