@llblab/pi-telegram 0.11.2 → 0.12.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.
@@ -7,7 +7,7 @@
7
7
  import {
8
8
  getTelegramExtensionSettingsRows,
9
9
  type TelegramSectionRegistry,
10
- } from "./extension-sections.ts";
10
+ } from "./sections.ts";
11
11
  import type { TelegramTimeMode } from "./config.ts";
12
12
  import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
13
13
  import type { TelegramModelMenuState } from "./menu-model.ts";
@@ -115,7 +115,8 @@ export interface TelegramSettingsMenuRuntimeDeps<
115
115
 
116
116
  export const SETTINGS_MENU_TITLE = "<b>⚙️ Settings:</b>";
117
117
  export const PROACTIVE_PUSH_SETTINGS_TITLE = "<b>📌 Proactive push:</b>";
118
- export const TIME_INJECTION_MODE_SETTINGS_TITLE = "<b>🕒 Time injection mode:</b>";
118
+ export const TIME_INJECTION_MODE_SETTINGS_TITLE =
119
+ "<b>🕒 Time injection mode:</b>";
119
120
  export const VOICE_REPLY_MODE_SETTINGS_TITLE = "<b>👄 Voice reply mode:</b>";
120
121
 
121
122
  type TelegramVoiceReplyModeSetting = TelegramVoiceReplyMode | "hidden";
@@ -124,6 +125,10 @@ function getVoiceReplyModeLabel(mode: TelegramVoiceReplyModeSetting): string {
124
125
  return mode;
125
126
  }
126
127
 
128
+ function getTelegramSettingsStateValueLabel(value: string): string {
129
+ return value.length > 0 ? value[0]!.toUpperCase() + value.slice(1) : value;
130
+ }
131
+
127
132
  function getVoiceReplyModeSetting(
128
133
  mode: TelegramVoiceReplyMode,
129
134
  configured: boolean,
@@ -197,21 +202,23 @@ export function buildTelegramSettingsMenuReplyMarkup(
197
202
  rows.push(
198
203
  [
199
204
  {
200
- text: `👄 Voice reply: ${getVoiceReplyModeLabel(
201
- getVoiceReplyModeSetting(voiceReplyMode, voiceReplyModeConfigured),
205
+ text: `👄 Voice reply: ${getTelegramSettingsStateValueLabel(
206
+ getVoiceReplyModeLabel(
207
+ getVoiceReplyModeSetting(voiceReplyMode, voiceReplyModeConfigured),
208
+ ),
202
209
  )}`,
203
210
  callback_data: "settings:open:voice-reply",
204
211
  },
205
212
  ],
206
213
  [
207
214
  {
208
- text: `🕒 Time injection: ${timeInjectionMode}`,
215
+ text: `🕒 Time injection: ${getTelegramSettingsStateValueLabel(timeInjectionMode)}`,
209
216
  callback_data: "settings:open:time-injection",
210
217
  },
211
218
  ],
212
219
  [
213
220
  {
214
- text: `📌 Proactive push: ${proactivePushEnabled ? "on" : "off"}`,
221
+ text: `📌 Proactive push: ${proactivePushEnabled ? "On" : "Off"}`,
215
222
  callback_data: "settings:open:proactive",
216
223
  },
217
224
  ],
@@ -251,11 +258,11 @@ export function buildProactivePushSettingsReplyMarkup(
251
258
  [{ text: "⬆️ Back", callback_data: "settings:list" }],
252
259
  [
253
260
  {
254
- text: proactivePushEnabled ? "🟢 on" : "⚫️ on",
261
+ text: proactivePushEnabled ? "🟢 On" : "⚫️ On",
255
262
  callback_data: "settings:set:proactive:on",
256
263
  },
257
264
  {
258
- text: proactivePushEnabled ? "⚫️ off" : "🟡 off",
265
+ text: proactivePushEnabled ? "⚫️ Off" : "🟡 Off",
259
266
  callback_data: "settings:set:proactive:off",
260
267
  },
261
268
  ],
@@ -372,7 +379,10 @@ export async function handleTelegramSettingsMenuCallbackAction(
372
379
  await deps.answerCallbackQuery(callbackQueryId);
373
380
  return true;
374
381
  }
375
- if (data === "settings:open:time-injection" || data === "settings:open:time") {
382
+ if (
383
+ data === "settings:open:time-injection" ||
384
+ data === "settings:open:time"
385
+ ) {
376
386
  await updateTimeInjectionModeSettingsMessage(deps);
377
387
  await deps.answerCallbackQuery(callbackQueryId);
378
388
  return true;
@@ -506,7 +516,8 @@ export function createTelegramSettingsMenuRuntime<
506
516
  ? query.data.slice("settings:set:time-injection:".length)
507
517
  : query.data.slice("settings:set:time:".length);
508
518
  if (
509
- (hasTimeInjectionPrefix || query.data.startsWith("settings:set:time:")) &&
519
+ (hasTimeInjectionPrefix ||
520
+ query.data.startsWith("settings:set:time:")) &&
510
521
  (timeMode === "off" ||
511
522
  timeMode === "hidden" ||
512
523
  timeMode === "always" ||
@@ -8,7 +8,7 @@ import { formatTelegramCommandEmojiPrefix } from "./commands.ts";
8
8
  import {
9
9
  getTelegramSectionMainMenuRows,
10
10
  type TelegramSectionRegistry,
11
- } from "./extension-sections.ts";
11
+ } from "./sections.ts";
12
12
  import {
13
13
  formatStatusButtonLabel,
14
14
  type TelegramMenuMessageRuntimeDeps,
package/lib/menu.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  handleTelegramSectionSettingsOpen,
11
11
  parseTelegramSectionCallback,
12
12
  type TelegramSectionRegistry,
13
- } from "./extension-sections.ts";
13
+ } from "./sections.ts";
14
14
  import {
15
15
  createTelegramModelMenuStateBuilder,
16
16
  handleTelegramModelMenuCallbackAction,
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Telegram outbound handler helpers
2
+ * Telegram outbound surface helpers
3
3
  * Zones: telegram outbound, assistant markup, command templates, callback routing
4
- * Owns assistant-authored outbound markup extraction, configured artifact generation, callback actions, and Telegram outbound delivery
4
+ * Owns assistant-authored outbound markup extraction, configured artifact generation, callback actions, runtime-event bridge, and Telegram outbound delivery
5
5
  */
6
6
 
7
7
  import { randomUUID } from "node:crypto";
@@ -61,6 +61,18 @@ const DEFAULT_VOICE_TIMEOUT_MS = 120_000;
61
61
  * diagnostics alongside pi-telegram's own events. Events are silently dropped
62
62
  * when pi-telegram is not loaded.
63
63
  */
64
+ export type TelegramRuntimeEventRecorder = (
65
+ category: string,
66
+ error: unknown,
67
+ details?: Record<string, unknown>,
68
+ ) => void;
69
+
70
+ export function bindTelegramRuntimeEventRecorder(
71
+ recorder: TelegramRuntimeEventRecorder,
72
+ ): void {
73
+ (globalThis as Record<string, unknown>)[VOICE_EVENT_RECORDER_KEY] = recorder;
74
+ }
75
+
64
76
  export function recordTelegramRuntimeEvent(
65
77
  category: string,
66
78
  error: unknown,
@@ -70,13 +82,7 @@ export function recordTelegramRuntimeEvent(
70
82
  VOICE_EVENT_RECORDER_KEY
71
83
  ];
72
84
  if (typeof recorder === "function") {
73
- (
74
- recorder as (
75
- category: string,
76
- error: unknown,
77
- details?: Record<string, unknown>,
78
- ) => void
79
- )(category, error, details);
85
+ (recorder as TelegramRuntimeEventRecorder)(category, error, details);
80
86
  }
81
87
  }
82
88
 
@@ -1028,7 +1034,9 @@ export function createTelegramVoiceReplySender(
1028
1034
  });
1029
1035
  return;
1030
1036
  } catch (error) {
1031
- deps.recordRuntimeEvent?.("voice", error, { phase: "template-handler-send" });
1037
+ deps.recordRuntimeEvent?.("voice", error, {
1038
+ phase: "template-handler-send",
1039
+ });
1032
1040
  }
1033
1041
  }
1034
1042
 
@@ -1045,7 +1053,9 @@ export function createTelegramVoiceReplySender(
1045
1053
  });
1046
1054
  return;
1047
1055
  } catch (error) {
1048
- deps.recordRuntimeEvent?.("voice", error, { phase: "programmatic-handler-send" });
1056
+ deps.recordRuntimeEvent?.("voice", error, {
1057
+ phase: "programmatic-handler-send",
1058
+ });
1049
1059
  }
1050
1060
  }
1051
1061
 
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
@@ -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/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/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,13 +12,14 @@
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
 
@@ -81,11 +82,20 @@ function getOrCreateVoiceSynthesisProviderRegistry(): Map<
81
82
  if (existing instanceof Map)
82
83
  return existing as Map<string, TelegramVoiceSynthesisProvider>;
83
84
  const registry = new Map<string, TelegramVoiceSynthesisProvider>();
84
- (globalThis as Record<string, unknown>)[VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY] =
85
- registry;
85
+ (globalThis as Record<string, unknown>)[
86
+ VOICE_SYNTHESIS_PROVIDER_REGISTRY_KEY
87
+ ] = registry;
86
88
  return registry;
87
89
  }
88
90
 
91
+ /**
92
+ * Register a high-level Telegram voice synthesis provider.
93
+ *
94
+ * Stable public API callers must pass a stable `options.id` so diagnostics,
95
+ * replacement, and cleanup can identify the provider. Omitted ids remain a
96
+ * compatibility path for pre-matrix callers and receive generated session-local
97
+ * ids.
98
+ */
89
99
  export function registerTelegramVoiceSynthesisProvider(
90
100
  provider:
91
101
  | TelegramVoiceSynthesisProvider
@@ -103,9 +113,11 @@ export function registerTelegramVoiceSynthesisProvider(
103
113
  (text: string, options?: { lang?: string; rate?: string }) =>
104
114
  provider(text, options),
105
115
  {
106
- getVoicePolicy: (provider as TelegramVoiceSynthesisProvider).getVoicePolicy,
107
- getVoicePromptContribution: (provider as TelegramVoiceSynthesisProvider)
108
- .getVoicePromptContribution,
116
+ getVoicePolicy: (provider as TelegramVoiceSynthesisProvider)
117
+ .getVoicePolicy,
118
+ getVoicePromptContribution: (
119
+ provider as TelegramVoiceSynthesisProvider
120
+ ).getVoicePromptContribution,
109
121
  },
110
122
  ) as TelegramVoiceSynthesisProvider)
111
123
  : provider;
@@ -144,6 +156,13 @@ function getOrCreateVoiceTranscriptionProviderRegistry(): Map<
144
156
  return registry;
145
157
  }
146
158
 
159
+ /**
160
+ * Register a high-level Telegram voice transcription provider.
161
+ *
162
+ * Stable public API callers must pass a stable `options.id`. Omitted ids remain
163
+ * a compatibility path for pre-matrix callers and receive generated
164
+ * session-local ids.
165
+ */
147
166
  export function registerTelegramVoiceTranscriptionProvider(
148
167
  provider: TelegramVoiceTranscriptionProvider,
149
168
  options?: { id?: string },
@@ -182,9 +201,9 @@ export const TELEGRAM_VOICE_REPLY_MODES = [
182
201
  * Pi-telegram owns reply-mode policy through telegram.json. If
183
202
  * config.voice.replyMode is missing or invalid, the safe default is manual.
184
203
  */
185
- export function getTelegramVoiceReplyMode(
186
- config?: { voice?: { replyMode?: string } },
187
- ): TelegramVoiceReplyMode {
204
+ export function getTelegramVoiceReplyMode(config?: {
205
+ voice?: { replyMode?: string };
206
+ }): TelegramVoiceReplyMode {
188
207
  const configMode = config?.voice?.replyMode;
189
208
  if (
190
209
  configMode &&
@@ -202,9 +221,9 @@ export function getTelegramVoiceReplyMode(
202
221
  * Reads from `config.voice.sendTranscript`.
203
222
  * Default: false (no transcript text sent at all).
204
223
  */
205
- export function getTelegramVoiceSendTranscript(
206
- config?: { voice?: { sendTranscript?: boolean } },
207
- ): boolean {
224
+ export function getTelegramVoiceSendTranscript(config?: {
225
+ voice?: { sendTranscript?: boolean };
226
+ }): boolean {
208
227
  return !!config?.voice?.sendTranscript;
209
228
  }
210
229
 
@@ -292,4 +311,4 @@ export {
292
311
  stripTelegramCommentMarkupForDelivery,
293
312
  stripTelegramVoiceMarkupForPreview,
294
313
  normalizeMarkdownAfterVoiceExtraction,
295
- } from "./outbound-handlers.ts";
314
+ } from "./outbound.ts";
package/package.json CHANGED
@@ -1,12 +1,21 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
8
  "description": "Telegram runtime adapter for π",
9
9
  "type": "module",
10
+ "exports": {
11
+ ".": "./index.ts",
12
+ "./inbound": "./api/inbound.ts",
13
+ "./outbound": "./api/outbound.ts",
14
+ "./updates": "./api/updates.ts",
15
+ "./sections": "./api/sections.ts",
16
+ "./voice": "./api/voice.ts",
17
+ "./keyboard": "./api/keyboard.ts"
18
+ },
10
19
  "keywords": [
11
20
  "pi-package",
12
21
  "pi",
@@ -35,6 +44,7 @@
35
44
  },
36
45
  "files": [
37
46
  "index.ts",
47
+ "api/",
38
48
  "lib/",
39
49
  "README.md",
40
50
  "AGENTS.md",