@llblab/pi-telegram 0.11.1 → 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.
Files changed (45) hide show
  1. package/AGENTS.md +17 -12
  2. package/BACKLOG.md +0 -10
  3. package/CHANGELOG.md +33 -2
  4. package/README.md +42 -25
  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 +176 -135
  13. package/docs/callback-namespaces.md +3 -3
  14. package/docs/command-templates.md +81 -24
  15. package/docs/{inbound-handlers.md → inbound.md} +13 -10
  16. package/docs/locks.md +3 -3
  17. package/docs/{outbound-handlers.md → outbound.md} +13 -10
  18. package/docs/public-api.md +266 -0
  19. package/docs/{extension-sections.md → sections.md} +31 -27
  20. package/docs/ui-style.md +165 -0
  21. package/docs/{external-handlers.md → updates.md} +33 -31
  22. package/docs/voice.md +17 -14
  23. package/index.ts +86 -261
  24. package/lib/bindings.ts +301 -0
  25. package/lib/command-templates.ts +163 -32
  26. package/lib/commands.ts +114 -1
  27. package/lib/config.ts +45 -4
  28. package/lib/{inbound-handlers.ts → inbound.ts} +5 -4
  29. package/lib/lifecycle.ts +122 -1
  30. package/lib/menu-model.ts +3 -3
  31. package/lib/menu-queue.ts +1 -1
  32. package/lib/menu-settings.ts +63 -32
  33. package/lib/menu-status.ts +1 -1
  34. package/lib/menu.ts +1 -1
  35. package/lib/{outbound-handlers.ts → outbound.ts} +21 -11
  36. package/lib/pi.ts +4 -0
  37. package/lib/polling.ts +4 -3
  38. package/lib/preview.ts +1 -1
  39. package/lib/routing.ts +45 -13
  40. package/lib/{extension-sections.ts → sections.ts} +37 -8
  41. package/lib/time-injection.ts +1 -1
  42. package/lib/updates.ts +121 -1
  43. package/lib/voice.ts +33 -14
  44. package/package.json +11 -1
  45. package/lib/external-handlers.ts +0 -166
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.1",
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",
@@ -1,166 +0,0 @@
1
- /**
2
- * External Telegram handler registry
3
- * Zones: telegram transport, layered extension interop
4
- * Lets other pi extensions hook into the polling loop without owning their own getUpdates connection
5
- */
6
-
7
- /**
8
- * Verdict returned by an interceptor.
9
- *
10
- * - `"consume"` — the interceptor handled this update; pi-telegram skips default routing.
11
- * - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
12
- */
13
- export type TelegramExternalHandlerVerdict = "consume" | "pass";
14
-
15
- export type TelegramExternalHandler = (
16
- update: unknown,
17
- ) =>
18
- | TelegramExternalHandlerVerdict
19
- | void
20
- | Promise<TelegramExternalHandlerVerdict | void>;
21
-
22
- export interface TelegramExternalHandlerRegistry {
23
- /** Schema version of this registry shape. */
24
- readonly version: 1;
25
- /**
26
- * Register an interceptor. Returns a disposer that removes it.
27
- *
28
- * Interceptors are invoked in registration order on every Telegram update,
29
- * before pi-telegram's own routing. The first interceptor that returns
30
- * `"consume"` wins and stops the chain for that update.
31
- */
32
- add: (handler: TelegramExternalHandler) => () => void;
33
- /**
34
- * Run all registered interceptors against an update.
35
- *
36
- * Used by pi-telegram's polling runtime; layered extensions should call
37
- * {@link onTelegramExternalUpdate} or `add` instead of dispatching directly.
38
- */
39
- dispatch: (update: unknown) => Promise<TelegramExternalHandlerVerdict>;
40
- }
41
-
42
- const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
43
-
44
- /**
45
- * Validate that a value on `globalThis` matches the full v1 registry contract.
46
- *
47
- * pi-telegram's polling runtime invokes `dispatch`, so a partial object that
48
- * only carries `version` and `add` (which an early draft of the zero-coupling
49
- * docs showed) would silently break the first update. We treat any object
50
- * tagged `version === 1` but missing required methods as malformed and
51
- * replace it with a fresh, fully-formed registry. Layered extensions that
52
- * follow the full documented shape are unaffected; ones that don't lose any
53
- * handlers they registered against the malformed object, which is the
54
- * desired fail-loud-during-development behavior.
55
- */
56
- function isValidV1Registry(
57
- candidate: unknown,
58
- ): candidate is TelegramExternalHandlerRegistry {
59
- if (!candidate || typeof candidate !== "object") return false;
60
- const r = candidate as Partial<TelegramExternalHandlerRegistry>;
61
- return (
62
- r.version === 1 &&
63
- typeof r.add === "function" &&
64
- typeof r.dispatch === "function"
65
- );
66
- }
67
-
68
- function getOrCreateRegistry(): TelegramExternalHandlerRegistry {
69
- const g = globalThis as Record<string, unknown>;
70
- const existing = g[REGISTRY_KEY];
71
- if (isValidV1Registry(existing)) return existing;
72
- const handlers = new Set<TelegramExternalHandler>();
73
- const registry: TelegramExternalHandlerRegistry = {
74
- version: 1,
75
- add(handler) {
76
- handlers.add(handler);
77
- return () => handlers.delete(handler);
78
- },
79
- async dispatch(update) {
80
- for (const handler of handlers) {
81
- try {
82
- const result = await handler(update);
83
- if (result === "consume") return "consume";
84
- } catch {
85
- // External handler errors must not break polling.
86
- }
87
- }
88
- return "pass";
89
- },
90
- };
91
- g[REGISTRY_KEY] = registry;
92
- return registry;
93
- }
94
-
95
- /**
96
- * Called by pi-telegram's own runtime to obtain the registry it dispatches
97
- * through. Layered extensions should not call this; use
98
- * {@link onTelegramExternalUpdate} instead.
99
- */
100
- export function getTelegramExternalHandlerRegistry(): TelegramExternalHandlerRegistry {
101
- return getOrCreateRegistry();
102
- }
103
-
104
- export interface TelegramExternalHandlerWrapDeps<TUpdate, TContext> {
105
- defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
106
- registry?: TelegramExternalHandlerRegistry;
107
- }
108
- export type TelegramExternalInterceptorWrapDeps<TUpdate, TContext> =
109
- TelegramExternalHandlerWrapDeps<TUpdate, TContext>;
110
-
111
- /**
112
- * Wrap a default polling `handleUpdate` with the external interceptor registry.
113
- *
114
- * Returned function dispatches `update` through registered interceptors first;
115
- * if any returns `"consume"`, default routing is skipped for that update.
116
- *
117
- * Composition-root callers (pi-telegram's `index.ts`) should use this builder
118
- * instead of writing the lifting logic inline.
119
- */
120
- export function createTelegramExternalHandleUpdate<TUpdate, TContext>(
121
- deps: TelegramExternalHandlerWrapDeps<TUpdate, TContext>,
122
- ): (update: TUpdate, ctx: TContext) => Promise<void> {
123
- const registry = deps.registry ?? getOrCreateRegistry();
124
- const { defaultHandle } = deps;
125
- return async function handleInterceptedUpdate(update, ctx) {
126
- const verdict = await registry.dispatch(update);
127
- if (verdict === "consume") return;
128
- await defaultHandle(update, ctx);
129
- };
130
- }
131
-
132
- /**
133
- * Register an interceptor that runs before pi-telegram routes a Telegram
134
- * update through its built-in handlers (commands, app menu, queue menu,
135
- * model menu, default prompt routing).
136
- *
137
- * This is the recommended public surface for layered extensions that share
138
- * the same bot and pi process with pi-telegram (single bot ↔ single
139
- * `getUpdates` poller).
140
- *
141
- * Returns a disposer that removes the interceptor.
142
- *
143
- * @example
144
- * ```ts
145
- * import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
146
- *
147
- * const off = onTelegramExternalUpdate(async (update) => {
148
- * const cb = (update as { callback_query?: { data?: string } }).callback_query;
149
- * if (!cb?.data?.startsWith("myext:")) return "pass";
150
- * await handleMyCallback(cb);
151
- * return "consume"; // skip pi-telegram's default routing for this update
152
- * });
153
- *
154
- * // later, e.g. on session shutdown:
155
- * off();
156
- * ```
157
- *
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.
161
- */
162
- export function onTelegramExternalUpdate(
163
- handler: TelegramExternalHandler,
164
- ): () => void {
165
- return getOrCreateRegistry().add(handler);
166
- }