@llblab/pi-telegram 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3: External Handlers Rename
4
+
5
+ - `[External Handlers]` Renamed the external update handlers domain to `external-handlers` across source, tests, and docs. Impact: the interop domain now has a cleaner name aligned with inbound/outbound handler naming.
6
+ - `[Breaking]` Removed the old `external-update-handlers` module/doc path and old exported update/interceptor aliases. Impact: layered extensions should import from `@llblab/pi-telegram/lib/external-handlers.ts` and use the `TelegramExternalHandler*` names.
7
+ - `[Package]` Bumped package metadata to `0.9.3` and kept the lockfile in sync.
8
+
9
+ ## 0.9.2: External Update Interceptors
10
+
11
+ - `[External Update Interceptors]` Added a versioned `globalThis` registry that lets layered pi extensions observe and optionally consume Telegram updates before pi-telegram's default routing. Impact: approval gates and other same-process extensions can react synchronously to Telegram callbacks without owning a second bot poller.
12
+ - `[External Update Interceptors]` Validated the full v1 registry shape (`version`, `add`, and `dispatch`) before reusing a pre-existing global registry and documented the zero-coupling bootstrap contract. Impact: install-order interop stays safe even when another extension initializes the registry first.
13
+ - `[Queue Menu]` Non-empty queue lists now keep the `🌀 Refresh` row below queued items, matching the empty-queue surface. Impact: users can manually refresh the queue screen while waiting for changes without navigating away.
14
+ - `[Security]` Refreshed the lockfile to resolve the transitive `basic-ftp` audit advisory. Impact: release validation returns to a clean npm audit state.
15
+ - `[Package]` Bumped package metadata to `0.9.2` and kept the lockfile in sync.
16
+
3
17
  ## 0.9.1: Model Detail Hotfix
4
18
 
5
19
  - `[Model Menu]` Detail-mode activation now preserves scoped `thinkingLevel` by resolving the selected scoped entry before falling back to the unscoped model list. Impact: scoped model shortcuts opened through the detail submenu keep their reasoning/thinking level.
package/README.md CHANGED
@@ -224,7 +224,7 @@ List the main risks first.
224
224
  <!-- telegram_button: OK -->
225
225
  ```
226
226
 
227
- Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Unknown inline-button callbacks that do not belong to pi-telegram are forwarded to π as `[callback] <data>` so other extensions can namespace and handle their own Telegram buttons without polling the bot themselves; see the [Callback Namespace Standard](./docs/callback-namespaces.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
227
+ Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Unknown inline-button callbacks that do not belong to pi-telegram are forwarded to π as `[callback] <data>` so other extensions can namespace and handle their own Telegram buttons without polling the bot themselves; see the [Callback Namespace Standard](./docs/callback-namespaces.md). Layered extensions that need to react to Telegram updates synchronously inside their own runtime (for example, to resolve a blocking-tool approval Promise the moment a callback arrives) can register a runtime interceptor on the shared update registry; see [External Handlers](./docs/external-handlers.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
228
228
 
229
229
  ## Streaming
230
230
 
package/docs/README.md CHANGED
@@ -10,3 +10,4 @@ Living index of project documentation in `/docs`.
10
10
  - [outbound-handlers.md](./outbound-handlers.md) — Local `pi-telegram` outbound-handler config, text/voice/button behavior, artifact outputs, and callback routing
11
11
  - [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
12
12
  - [callback-namespaces.md](./callback-namespaces.md) — Shared Telegram `callback_data` namespace standard for layered extensions
13
+ - [external-handlers.md](./external-handlers.md) — Runtime interceptor registry that lets layered extensions observe and consume Telegram updates without owning their own polling connection
@@ -0,0 +1,193 @@
1
+ # External Handlers
2
+
3
+ `pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other
4
+ pi extensions cannot open a competing poller against the same bot — the
5
+ Telegram Bot API uses a per-bot `offset` cursor, and two pollers race each
6
+ other and lose updates.
7
+
8
+ This document describes the registry that lets layered pi extensions
9
+ (running in the same pi process) hook into `pi-telegram`'s polling loop and
10
+ react to inbound Telegram updates **before** `pi-telegram`'s default routing
11
+ fires.
12
+
13
+ It is the runtime counterpart to
14
+ [Callback Namespaces](./callback-namespaces.md): callback namespaces define
15
+ how to share `callback_data` cleanly; external handlers define how to
16
+ observe and optionally short-circuit the dispatch of those updates.
17
+
18
+ ## When to use it
19
+
20
+ Use it when a layered extension needs to:
21
+
22
+ - Resolve out-of-band state (for example, a `tool_call` approval Promise)
23
+ the moment a Telegram callback arrives, rather than waiting for the next
24
+ agent turn.
25
+ - Suppress `pi-telegram`'s default routing for callbacks owned by the
26
+ layered extension (so `pi-telegram` does not also forward them as
27
+ `[callback] <data>` text).
28
+ - Observe arbitrary update types (messages, edits, channel posts, reactions)
29
+ without owning the polling connection.
30
+
31
+ If the layered extension only needs to read assistant-visible callbacks, the
32
+ existing `[callback] <data>` fallback documented in
33
+ [Callback Namespaces](./callback-namespaces.md) is enough.
34
+
35
+ ## Constraints
36
+
37
+ - One bot, one pi process, one `getUpdates` poller. This registry does **not**
38
+ enable running multiple pi instances against the same bot.
39
+ - Interceptors run in the polling loop. They must return quickly; long
40
+ awaits delay subsequent updates.
41
+ - Interceptor errors are caught and logged silently so polling never breaks.
42
+ If you need durable error reporting, do it inside your interceptor.
43
+ - The registry lives on `globalThis`. Module instance identity is not
44
+ required, so layered extensions can reach it without importing
45
+ `@llblab/pi-telegram`.
46
+
47
+ ## Verdicts
48
+
49
+ Each interceptor returns one of:
50
+
51
+ - `"consume"` — `pi-telegram` skips its default routing for this update.
52
+ - `"pass"` (or `void` / `undefined`) — `pi-telegram` routes the update
53
+ normally. Other interceptors registered after this one still run for the
54
+ same update.
55
+
56
+ The first interceptor that returns `"consume"` wins; later interceptors are
57
+ not called for that update.
58
+
59
+ ## Registering an interceptor
60
+
61
+ Two equivalent paths.
62
+
63
+ ### Typed import (recommended when you can depend on `@llblab/pi-telegram`)
64
+
65
+ ```ts
66
+ import { onTelegramExternalUpdate } from "@llblab/pi-telegram/lib/external-handlers.ts";
67
+
68
+ const off = onTelegramExternalUpdate(async (update) => {
69
+ const cb = (update as { callback_query?: { id?: string; data?: string } })
70
+ .callback_query;
71
+ if (!cb?.data?.startsWith("myext:")) return "pass";
72
+ await resolveMyApproval(cb);
73
+ return "consume";
74
+ });
75
+
76
+ // Later, when your extension shuts down:
77
+ off();
78
+ ```
79
+
80
+ ### Zero-coupling globalThis lookup
81
+
82
+ When the layered extension prefers no `import` from `@llblab/pi-telegram` (so
83
+ load order between the two extensions does not matter, and either can be
84
+ installed first), it must implement the **full v1 registry contract**, not
85
+ just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on
86
+ whatever object it finds at `globalThis.__piTelegramExternalHandlerRegistry__`,
87
+ so a partial object would silently break the first update.
88
+
89
+ pi-telegram defensively re-creates the registry if the object on `globalThis`
90
+ is missing `add` or `dispatch` (validated as `version === 1`,
91
+ `typeof add === "function"`, `typeof dispatch === "function"`). Handlers
92
+ registered against a malformed object are dropped — make sure your bootstrap
93
+ implements all three fields.
94
+
95
+ ```ts
96
+ type PiTelegramVerdict =
97
+ | "consume"
98
+ | "pass"
99
+ | void
100
+ | Promise<"consume" | "pass" | void>;
101
+ type PiTelegramInterceptor = (update: unknown) => PiTelegramVerdict;
102
+
103
+ interface PiTelegramExternalHandlerRegistry {
104
+ readonly version: 1;
105
+ add: (handler: PiTelegramInterceptor) => () => void;
106
+ // Required: pi-telegram's polling loop calls this on every update.
107
+ dispatch: (update: unknown) => Promise<"consume" | "pass">;
108
+ }
109
+
110
+ const REGISTRY_KEY = "__piTelegramExternalHandlerRegistry__";
111
+
112
+ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
113
+ const g = globalThis as Record<string, unknown>;
114
+ const existing = g[REGISTRY_KEY] as
115
+ | PiTelegramExternalHandlerRegistry
116
+ | undefined;
117
+ if (
118
+ existing &&
119
+ existing.version === 1 &&
120
+ typeof existing.add === "function" &&
121
+ typeof existing.dispatch === "function"
122
+ ) {
123
+ return existing;
124
+ }
125
+ const handlers = new Set<PiTelegramInterceptor>();
126
+ const registry: PiTelegramExternalHandlerRegistry = {
127
+ version: 1,
128
+ add(handler) {
129
+ handlers.add(handler);
130
+ return () => handlers.delete(handler);
131
+ },
132
+ async dispatch(update) {
133
+ for (const handler of handlers) {
134
+ try {
135
+ const result = await handler(update);
136
+ if (result === "consume") return "consume";
137
+ } catch {
138
+ // Never break polling because of an interceptor error.
139
+ }
140
+ }
141
+ return "pass";
142
+ },
143
+ };
144
+ g[REGISTRY_KEY] = registry;
145
+ return registry;
146
+ }
147
+
148
+ const off = getOrCreateRegistry().add((update) => {
149
+ /* … */
150
+ return "pass";
151
+ });
152
+ ```
153
+
154
+ The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is
155
+ versioned (`version: 1`) and stable across pi-telegram releases; future
156
+ breaking changes will use a new schema version and a new key.
157
+
158
+ ## Interaction with built-in routing
159
+
160
+ `pi-telegram` invokes registered interceptors first, then routes the update
161
+ through its own handlers (commands, app menu, queue menu, model menu,
162
+ default prompt routing, callback namespace fallback). If any interceptor
163
+ returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
164
+
165
+ This means:
166
+
167
+ - Extensions can claim callback namespaces that `pi-telegram` would
168
+ otherwise forward as `[callback] <data>` text.
169
+ - Extensions can observe (but not consume) updates by always returning
170
+ `"pass"`.
171
+ - Extensions must not consume updates that belong to `pi-telegram`'s own
172
+ prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`)
173
+ unless they are deliberately replacing that behavior.
174
+
175
+ ## Ownership semantics
176
+
177
+ The interceptor registry is ownership-agnostic and does not interact with
178
+ the `locks.json` singleton lock documented in [Locks](./locks.md). When the
179
+ locked polling runtime stops `pi-telegram`'s poller (for example, after
180
+ ownership is moved to another pi process), interceptors stop receiving
181
+ updates because no updates are being fetched. They are not unregistered.
182
+
183
+ If a layered extension needs to react to ownership changes, it should
184
+ observe `pi-telegram` lifecycle events through the standard pi extension
185
+ hooks rather than through the interceptor registry.
186
+
187
+ ## Not a multiplexer
188
+
189
+ This registry does not multiplex one bot across multiple pi processes, and
190
+ it does not bypass Telegram's single-poller-per-bot constraint. To run
191
+ multiple pi instances on Telegram, give each instance its own bot and its
192
+ own `~/.pi/agent` directory; the registry is for layered extensions inside
193
+ **one** pi process.
package/index.ts CHANGED
@@ -8,6 +8,7 @@ import * as Api from "./lib/api.ts";
8
8
  import * as CommandTemplates from "./lib/command-templates.ts";
9
9
  import * as Commands from "./lib/commands.ts";
10
10
  import * as Config from "./lib/config.ts";
11
+ import { createTelegramExternalHandleUpdate } from "./lib/external-handlers.ts";
11
12
  import * as InboundHandlers from "./lib/inbound-handlers.ts";
12
13
  import * as Keyboard from "./lib/keyboard.ts";
13
14
  import * as Lifecycle from "./lib/lifecycle.ts";
@@ -358,7 +359,9 @@ export default function (pi: Pi.ExtensionAPI) {
358
359
  deleteWebhook,
359
360
  getUpdates,
360
361
  persistConfig: configStore.persist,
361
- handleUpdate: inboundRouteRuntime.handleUpdate,
362
+ handleUpdate: createTelegramExternalHandleUpdate({
363
+ defaultHandle: inboundRouteRuntime.handleUpdate,
364
+ }),
362
365
  stopTypingLoop: typing.stop,
363
366
  updateStatus,
364
367
  recordRuntimeEvent,
@@ -0,0 +1,167 @@
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 registry directly
159
+ * via `globalThis.__piTelegramExternalHandlerRegistry__` (versioned object,
160
+ * see {@link TelegramExternalHandlerRegistry}). This avoids importing
161
+ * `@llblab/pi-telegram` and tolerates either install order.
162
+ */
163
+ export function onTelegramExternalUpdate(
164
+ handler: TelegramExternalHandler,
165
+ ): () => void {
166
+ return getOrCreateRegistry().add(handler);
167
+ }
package/lib/menu-queue.ts CHANGED
@@ -57,10 +57,8 @@ function buildTelegramQueueMenuReplyMarkup(
57
57
  items: readonly TelegramQueueMenuItem[],
58
58
  ): TelegramQueueMenuReplyMarkup {
59
59
  const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
60
- if (items.length === 0) {
61
- const refreshRow = [{ text: "🌀 Refresh", callback_data: "queue:refresh" }];
62
- return { inline_keyboard: [backRow, refreshRow] };
63
- }
60
+ const refreshRow = [{ text: "🌀 Refresh", callback_data: "queue:refresh" }];
61
+ if (items.length === 0) return { inline_keyboard: [backRow, refreshRow] };
64
62
  const rows = items.map(function buildTelegramQueueMenuRow(item, index) {
65
63
  const prefix = item.isPriority
66
64
  ? `${item.priorityEmoji ?? "⚡"} `
@@ -75,7 +73,7 @@ function buildTelegramQueueMenuReplyMarkup(
75
73
  },
76
74
  ];
77
75
  });
78
- return { inline_keyboard: [backRow, ...rows] };
76
+ return { inline_keyboard: [backRow, ...rows, refreshRow] };
79
77
  }
80
78
  function findTelegramQueueItem<Context>(
81
79
  items: readonly Queue.TelegramQueueItem<Context>[],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for π",
6
6
  "type": "module",