@llblab/pi-telegram 0.9.0 β†’ 0.9.2

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2: External Update Interceptors
4
+
5
+ - `[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.
6
+ - `[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.
7
+ - `[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.
8
+ - `[Security]` Refreshed the lockfile to resolve the transitive `basic-ftp` audit advisory. Impact: release validation returns to a clean npm audit state.
9
+ - `[Package]` Bumped package metadata to `0.9.2` and kept the lockfile in sync.
10
+
11
+ ## 0.9.1: Model Detail Hotfix
12
+
13
+ - `[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.
14
+ - `[Model Menu]` Activating an already active model from the detail submenu now still runs the refresh path that applies scoped thinking changes while returning to the model list. Impact: tapping Active can still correct the thinking level instead of becoming a no-op.
15
+ - `[Proactive Push]` Removed the unused proactive reply-target store and always sends proactive local-result pushes without `reply_to_message_id`. Impact: the runtime no longer carries dead state for a target-capture behavior that does not exist yet.
16
+ - `[Queue Reactions]` Added `πŸ”₯` as a priority reaction and `πŸ—‘` as a queue-removal reaction. Impact: the intuitive fire/removal gestures now work alongside the existing reaction controls.
17
+ - `[Docs]` Updated the status-bar example to match the compact active/queued display.
18
+ - `[Package]` Bumped package metadata to `0.9.1` and kept the lockfile in sync.
19
+
3
20
  ## 0.9.0: Hidden Settings And Proactive Push
4
21
 
5
22
  - `[Settings Menu]` Added hidden Telegram `/settings` with a proactive push checkbox detail submenu plus `/telegram-settings` in the terminal. Impact: operators can see green/black binary flag state, use green/black/yellow On/Off checkbox controls from Telegram, and toggle the same proactive push flag locally without adding a visible bot-command entry.
package/README.md CHANGED
@@ -110,8 +110,8 @@ Run these inside Ο€, not Telegram:
110
110
 
111
111
  - If you send more Telegram messages while Ο€ is busy, they enter the default prompt queue and are processed in order.
112
112
  - Very long text messages that Telegram appears to split automatically are coalesced through a short conservative debounce and forwarded to Ο€ as one prompt when the first chunk is near Telegram's text limit, currently using a 3600-character threshold. Commands, bot messages, media groups, and normal short follow-ups are not coalesced.
113
- - `πŸ‘`, `⚑️`, `❀️`, and `πŸ•Š` move a waiting prompt into the priority prompt queue, behind control actions but ahead of default prompts. Removing the last priority reaction sends it back to its normal queue position, and adding a priority reaction again gives it a fresh priority position.
114
- - `πŸ‘Ž`, `πŸ‘»`, `πŸ’”`, and `πŸ’©` remove a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to removal reactions.
113
+ - `πŸ‘`, `⚑️`, `❀️`, `πŸ•Š`, and `πŸ”₯` move a waiting prompt into the priority prompt queue, behind control actions but ahead of default prompts. Removing the last priority reaction sends it back to its normal queue position, and adding a priority reaction again gives it a fresh priority position.
114
+ - `πŸ‘Ž`, `πŸ‘»`, `πŸ’”`, `πŸ’©`, and `πŸ—‘` remove a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to removal reactions.
115
115
  - Reactions apply to any waiting Telegram turn, including text, voice, files, images, and media groups. For media groups, a reaction on any message in the group applies to the whole queued turn.
116
116
  - If you edit a Telegram message while it is still waiting in the queue, the queued turn is updated instead of creating a duplicate prompt. Edits after a turn has already started may not affect the active run.
117
117
  - Telegram replies to earlier text or caption messages are forwarded as `[reply]` context for normal prompts, while slash commands still parse from the new message text only.
@@ -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 Update Handlers](./docs/external-update-handlers.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
228
228
 
229
229
  ## Streaming
230
230
 
@@ -237,7 +237,7 @@ Rich previews are sent through editable messages because Telegram drafts are tex
237
237
  The Ο€ status bar shows the current bridge state plus queued Telegram turns as compact previews. Busy labels distinguish states such as `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`. Telegram prompt guidance asks agents to keep tables, dense list items, and compact text blocks within about 37 visible cells when possible so mobile replies stay readable.
238
238
 
239
239
  ```text
240
- telegram queued +3: [⚑ write a shell script…, summarize this image…, πŸ“Ž 2 attachments]
240
+ telegram active +3
241
241
  ```
242
242
 
243
243
  ## Notes
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-update-handlers.md](./external-update-handlers.md) β€” Runtime interceptor registry that lets layered extensions observe and consume Telegram updates without owning their own polling connection
@@ -23,24 +23,24 @@ Naming rule: because the repository already scopes this codebase to Telegram, ex
23
23
 
24
24
  Current runtime areas use these ownership boundaries:
25
25
 
26
- | Domain | Owns |
27
- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
- | `index.ts` | Single composition root for live Ο€/Telegram ports, session state, API-bound transport adapters, and status updates |
29
- | `api` | Bot API transport shapes/helpers, retries, file download, temp-dir lifecycle, inbound limits, chat actions, lazy bot-token clients, runtime error recording |
30
- | `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
31
- | `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
32
- | `updates` / `routing` | Update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition |
33
- | `media` / `text-groups` / `turns` / `inbound-handlers` | Text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, inbound text/media handler execution, turn building/editing, image reads, legacy `attachmentHandlers` compatibility |
34
- | `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
35
- | `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
26
+ | Domain | Owns |
27
+ | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
+ | `index.ts` | Single composition root for live Ο€/Telegram ports, session state, API-bound transport adapters, and status updates |
29
+ | `api` | Bot API transport shapes/helpers, retries, file download, temp-dir lifecycle, inbound limits, chat actions, lazy bot-token clients, runtime error recording |
30
+ | `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
31
+ | `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
32
+ | `updates` / `routing` | Update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition |
33
+ | `media` / `text-groups` / `turns` / `inbound-handlers` | Text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, inbound text/media handler execution, turn building/editing, image reads, legacy `attachmentHandlers` compatibility |
34
+ | `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
35
+ | `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
36
36
  | `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `menu-settings` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, model-menu UI, thinking-menu UI, status-menu UI, inline application callback composition, queue-menu UI, hidden settings UI, slash commands, bot command registration |
37
- | `keyboard` | Shared Telegram inline-keyboard reply-markup structure; feature domains own callback semantics and button construction |
38
- | `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
39
- | `outbound-handlers` | Outbound text transformation, assistant-authored outbound comments, generated reply artifacts, inline-keyboard callbacks, and post-`agent_end` outbound action delivery |
40
- | `outbound-attachments` | `telegram_attach` registration, outbound attachment queueing, stat/limit checks, photo/document delivery classification |
41
- | `status` | Status-bar/status-message rendering, queue-lane status views, redacted runtime event ring, grouped Ο€ diagnostics |
42
- | `lifecycle` / `prompts` / `prompt-templates` / `pi` | Ο€ hook registration, Telegram-specific before-agent prompt injection, Ο€ prompt-template discovery/expansion, centralized direct pi SDK imports and context adapters |
43
- | `command-templates` | Portable shell-free command-template standard helpers, composition expansion, placeholder substitution, and executable resolution |
37
+ | `keyboard` | Shared Telegram inline-keyboard reply-markup structure; feature domains own callback semantics and button construction |
38
+ | `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
39
+ | `outbound-handlers` | Outbound text transformation, assistant-authored outbound comments, generated reply artifacts, inline-keyboard callbacks, and post-`agent_end` outbound action delivery |
40
+ | `outbound-attachments` | `telegram_attach` registration, outbound attachment queueing, stat/limit checks, photo/document delivery classification |
41
+ | `status` | Status-bar/status-message rendering, queue-lane status views, redacted runtime event ring, grouped Ο€ diagnostics |
42
+ | `lifecycle` / `prompts` / `prompt-templates` / `pi` | Ο€ hook registration, Telegram-specific before-agent prompt injection, Ο€ prompt-template discovery/expansion, centralized direct pi SDK imports and context adapters |
43
+ | `command-templates` | Portable shell-free command-template standard helpers, composition expansion, placeholder substitution, and executable resolution |
44
44
 
45
45
  Boundary invariants:
46
46
 
@@ -95,13 +95,13 @@ Queued items now use two explicit dimensions:
95
95
 
96
96
  Admission contract:
97
97
 
98
- | Admission | Examples | Queue shape | Dispatch rank |
99
- | --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------- |
100
- | Immediate execution | `/compact`, `/queue`, `/stop`, `/help`, `/start` | Does not enter the Telegram queue; `/help` opens the same menu as `/start`; `/stop` also clears queued items | N/A |
101
- | Queued prompt command | `/continue`, `/template_name args` | `/continue` enqueues a Telegram-owned `continue` prompt; prompt-template commands expand the matching Ο€ template before entering the normal prompt queue | priority for `/continue`, otherwise default |
102
- | Control queue | Model-switch continuation turns and future deferred controls | `queueLane: control`; accepts control items and continuation prompts | 0 |
103
- | Priority prompt queue | A waiting prompt promoted by `πŸ‘`, `⚑️`, `❀️`, or `πŸ•Š` | `kind: prompt`, `queueLane: priority` | 1 |
104
- | Default prompt queue | Normal Telegram text/media turns | `kind: prompt`, `queueLane: default` | 2 |
98
+ | Admission | Examples | Queue shape | Dispatch rank |
99
+ | --------------------- | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- |
100
+ | Immediate execution | `/compact`, `/queue`, `/stop`, `/help`, `/start` | Does not enter the Telegram queue; `/help` opens the same menu as `/start`; `/stop` also clears queued items | N/A |
101
+ | Queued prompt command | `/continue`, `/template_name args` | `/continue` enqueues a Telegram-owned `continue` prompt; prompt-template commands expand the matching Ο€ template before entering the normal prompt queue | priority for `/continue`, otherwise default |
102
+ | Control queue | Model-switch continuation turns and future deferred controls | `queueLane: control`; accepts control items and continuation prompts | 0 |
103
+ | Priority prompt queue | A waiting prompt promoted by `πŸ‘`, `⚑️`, `❀️`, `πŸ•Š`, or `πŸ”₯` | `kind: prompt`, `queueLane: priority` | 1 |
104
+ | Default prompt queue | Normal Telegram text/media turns | `kind: prompt`, `queueLane: default` | 2 |
105
105
 
106
106
  The command action itself carries its execution mode, and the queue domain exposes lane contracts for admission mode, dispatch rank, and allowed item kinds. Queue append and planning paths validate lane admission so a malformed control/default or other invalid lane pairing fails predictably instead of silently changing priority. This lets synthetic control actions and Telegram prompts share one stable ordering model while still rendering distinctly in status output. In the Ο€ status bar, busy labels distinguish `active`, `dispatching`, `queued`, `tool running`, `model`, and `compacting`; priority prompts and priority control items are marked with `⚑`. If a queue mutation removes the last waiting item while Telegram-owned work still has running tools, the status remains yellow `active` instead of degrading to green `connected`.
107
107
 
@@ -181,7 +181,7 @@ Current operator controls include:
181
181
  - `/stop` for aborting the active Telegram-owned run and clearing waiting Telegram queue items
182
182
  - `/telegram-status` for Ο€-side diagnostics as grouped line-by-line sections separated by blank lines: connection, polling, execution, queue, and the recent redacted runtime/API event ring. These sections include polling state, last update id, active turn source ids, pending dispatch, compaction state, active tool count, pending model-switch state, total queue depth, and queue-lane counts. The event ring records transport/API, polling/update, prompt-dispatch, control-action, typing, compaction, setup, session-lifecycle, and attachment queue/delivery failures; benign unchanged edit responses and unsupported empty draft-clear attempts are filtered out so expected preview transport noise does not obscure real failures
183
183
  - `/telegram-settings` for Ο€-side bridge settings; it currently exposes proactive push as a local toggle backed by the same `telegram.json` flag as the hidden Telegram `/settings` menu
184
- - Queue reactions apply to waiting text, voice, file, image, and media-group turns by matching the turn's source Telegram message ids: `πŸ‘`, `⚑️`, `❀️`, and `πŸ•Š` promote waiting prompts, while `πŸ‘Ž`, `πŸ‘»`, `πŸ’”`, and `πŸ’©` remove waiting turns because ordinary Telegram DM message deletions are not exposed through the Bot API polling path this bridge uses
184
+ - Queue reactions apply to waiting text, voice, file, image, and media-group turns by matching the turn's source Telegram message ids: `πŸ‘`, `⚑️`, `❀️`, `πŸ•Š`, and `πŸ”₯` promote waiting prompts, while `πŸ‘Ž`, `πŸ‘»`, `πŸ’”`, `πŸ’©`, and `πŸ—‘` remove waiting turns because ordinary Telegram DM message deletions are not exposed through the Bot API polling path this bridge uses
185
185
 
186
186
  ## In-Flight Model Switching
187
187
 
@@ -0,0 +1,193 @@
1
+ # External Update 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 update 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 { onTelegramUpdate } from "@llblab/pi-telegram/lib/external-update-handlers.ts";
67
+
68
+ const off = onTelegramUpdate(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.__piTelegramExternalUpdateRegistry__`,
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 PiTelegramExternalUpdateRegistry {
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 = "__piTelegramExternalUpdateRegistry__";
111
+
112
+ function getOrCreateRegistry(): PiTelegramExternalUpdateRegistry {
113
+ const g = globalThis as Record<string, unknown>;
114
+ const existing = g[REGISTRY_KEY] as
115
+ | PiTelegramExternalUpdateRegistry
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: PiTelegramExternalUpdateRegistry = {
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.__piTelegramExternalUpdateRegistry__` 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 { createTelegramInterceptedHandleUpdate } from "./lib/external-update-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";
@@ -53,8 +54,6 @@ export default function (pi: Pi.ExtensionAPI) {
53
54
  Config.createTelegramProactivePushChecker(configStore);
54
55
  const setProactivePushEnabled =
55
56
  Config.createTelegramProactivePushSetter(configStore);
56
- const proactivePromptTargetStore =
57
- Config.createTelegramProactivePromptTargetStore();
58
57
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
59
58
  const lockOwnershipGuard =
60
59
  Locks.createTelegramLockOwnershipGuard(lockRuntime);
@@ -360,7 +359,9 @@ export default function (pi: Pi.ExtensionAPI) {
360
359
  deleteWebhook,
361
360
  getUpdates,
362
361
  persistConfig: configStore.persist,
363
- handleUpdate: inboundRouteRuntime.handleUpdate,
362
+ handleUpdate: createTelegramInterceptedHandleUpdate({
363
+ defaultHandle: inboundRouteRuntime.handleUpdate,
364
+ }),
364
365
  stopTypingLoop: typing.stop,
365
366
  updateStatus,
366
367
  recordRuntimeEvent,
@@ -496,7 +497,6 @@ export default function (pi: Pi.ExtensionAPI) {
496
497
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
497
498
  isCurrentOwner: lockOwnershipGuard.ownsContext,
498
499
  getDefaultChatId: proactivePushChatIdGetter,
499
- consumeProactiveReplyToMessageId: proactivePromptTargetStore.consumeForChat,
500
500
  isProactivePushEnabled,
501
501
  recordRuntimeEvent,
502
502
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
package/lib/config.ts CHANGED
@@ -148,31 +148,6 @@ export function createTelegramProactivePushChatIdGetter(deps: {
148
148
  return () => deps.getActiveTurnChatId() ?? deps.getAllowedUserId();
149
149
  }
150
150
 
151
- export interface TelegramProactivePromptTarget {
152
- chatId: number;
153
- messageId: number;
154
- }
155
-
156
- export interface TelegramProactivePromptTargetStore {
157
- set: (target: TelegramProactivePromptTarget) => void;
158
- consumeForChat: (chatId: number) => number | undefined;
159
- }
160
-
161
- export function createTelegramProactivePromptTargetStore(): TelegramProactivePromptTargetStore {
162
- let target: TelegramProactivePromptTarget | undefined;
163
- return {
164
- set: (nextTarget) => {
165
- target = nextTarget;
166
- },
167
- consumeForChat: (chatId) => {
168
- if (!target || target.chatId !== chatId) return undefined;
169
- const messageId = target.messageId;
170
- target = undefined;
171
- return messageId;
172
- },
173
- };
174
- }
175
-
176
151
  export type TelegramAuthorizationState =
177
152
  | { kind: "pair"; userId: number }
178
153
  | { kind: "allow" }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * External Telegram update interceptor 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 TelegramExternalUpdateVerdict = "consume" | "pass";
14
+
15
+ export type TelegramExternalUpdateInterceptor = (
16
+ update: unknown,
17
+ ) =>
18
+ | TelegramExternalUpdateVerdict
19
+ | void
20
+ | Promise<TelegramExternalUpdateVerdict | void>;
21
+
22
+ export interface TelegramExternalUpdateRegistry {
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: TelegramExternalUpdateInterceptor) => () => 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 onTelegramUpdate} or `add` instead of dispatching directly.
38
+ */
39
+ dispatch: (update: unknown) => Promise<TelegramExternalUpdateVerdict>;
40
+ }
41
+
42
+ const REGISTRY_KEY = "__piTelegramExternalUpdateRegistry__";
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 TelegramExternalUpdateRegistry {
59
+ if (!candidate || typeof candidate !== "object") return false;
60
+ const r = candidate as Partial<TelegramExternalUpdateRegistry>;
61
+ return (
62
+ r.version === 1 &&
63
+ typeof r.add === "function" &&
64
+ typeof r.dispatch === "function"
65
+ );
66
+ }
67
+
68
+ function getOrCreateRegistry(): TelegramExternalUpdateRegistry {
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<TelegramExternalUpdateInterceptor>();
73
+ const registry: TelegramExternalUpdateRegistry = {
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 onTelegramUpdate} instead.
99
+ */
100
+ export function getTelegramExternalUpdateRegistry(): TelegramExternalUpdateRegistry {
101
+ return getOrCreateRegistry();
102
+ }
103
+
104
+ export interface TelegramExternalInterceptorWrapDeps<TUpdate, TContext> {
105
+ defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
106
+ registry?: TelegramExternalUpdateRegistry;
107
+ }
108
+
109
+ /**
110
+ * Wrap a default polling `handleUpdate` with the external interceptor registry.
111
+ *
112
+ * Returned function dispatches `update` through registered interceptors first;
113
+ * if any returns `"consume"`, default routing is skipped for that update.
114
+ *
115
+ * Composition-root callers (pi-telegram's `index.ts`) should use this builder
116
+ * instead of writing the lifting logic inline.
117
+ */
118
+ export function createTelegramInterceptedHandleUpdate<TUpdate, TContext>(
119
+ deps: TelegramExternalInterceptorWrapDeps<TUpdate, TContext>,
120
+ ): (update: TUpdate, ctx: TContext) => Promise<void> {
121
+ const registry = deps.registry ?? getOrCreateRegistry();
122
+ const { defaultHandle } = deps;
123
+ return async function handleInterceptedUpdate(update, ctx) {
124
+ const verdict = await registry.dispatch(update);
125
+ if (verdict === "consume") return;
126
+ await defaultHandle(update, ctx);
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Register an interceptor that runs before pi-telegram routes a Telegram
132
+ * update through its built-in handlers (commands, app menu, queue menu,
133
+ * model menu, default prompt routing).
134
+ *
135
+ * This is the recommended public surface for layered extensions that share
136
+ * the same bot and pi process with pi-telegram (single bot ↔ single
137
+ * `getUpdates` poller).
138
+ *
139
+ * Returns a disposer that removes the interceptor.
140
+ *
141
+ * @example
142
+ * ```ts
143
+ * import { onTelegramUpdate } from "@llblab/pi-telegram/lib/external-update-handlers.ts";
144
+ *
145
+ * const off = onTelegramUpdate(async (update) => {
146
+ * const cb = (update as { callback_query?: { data?: string } }).callback_query;
147
+ * if (!cb?.data?.startsWith("myext:")) return "pass";
148
+ * await handleMyCallback(cb);
149
+ * return "consume"; // skip pi-telegram's default routing for this update
150
+ * });
151
+ *
152
+ * // later, e.g. on session shutdown:
153
+ * off();
154
+ * ```
155
+ *
156
+ * Extensions that prefer zero coupling can also reach the registry directly
157
+ * via `globalThis.__piTelegramExternalUpdateRegistry__` (versioned object,
158
+ * see {@link TelegramExternalUpdateRegistry}). This avoids importing
159
+ * `@llblab/pi-telegram` and tolerates either install order.
160
+ */
161
+ export function onTelegramUpdate(
162
+ handler: TelegramExternalUpdateInterceptor,
163
+ ): () => void {
164
+ return getOrCreateRegistry().add(handler);
165
+ }
package/lib/menu-model.ts CHANGED
@@ -620,6 +620,19 @@ export function applyTelegramModelDetailSelection(
620
620
  export function getTelegramSelectedDetailModel<
621
621
  TModel extends MenuModel = MenuModel,
622
622
  >(state: TelegramModelMenuState<TModel>): TelegramMenuSelectionResult<TModel> {
623
+ const indexedSelection = getTelegramModelSelection(
624
+ state,
625
+ state.selectedModelIndex?.toString(),
626
+ );
627
+ if (indexedSelection.kind === "selected") {
628
+ if (!state.selectedModelKey) return indexedSelection;
629
+ const indexedKey = getCanonicalModelId(
630
+ indexedSelection.selection.model,
631
+ ).toLowerCase();
632
+ if (indexedKey === state.selectedModelKey.toLowerCase()) {
633
+ return indexedSelection;
634
+ }
635
+ }
623
636
  if (state.selectedModelKey) {
624
637
  const lowerKey = state.selectedModelKey.toLowerCase();
625
638
  const selection = state.allModels.find(
@@ -627,7 +640,7 @@ export function getTelegramSelectedDetailModel<
627
640
  );
628
641
  if (selection) return { kind: "selected", selection };
629
642
  }
630
- return getTelegramModelSelection(state, state.selectedModelIndex?.toString());
643
+ return indexedSelection;
631
644
  }
632
645
 
633
646
  export function isTelegramModelScoped(
@@ -817,7 +830,6 @@ export function buildTelegramModelCallbackPlan<
817
830
  if (modelsMatch(selection.model, params.activeModel)) {
818
831
  if (action.action === "pick-selected") {
819
832
  focusTelegramModelListPage(params.state, selection.model);
820
- return { kind: "update-menu", text: `Model: ${selection.model.id}` };
821
833
  }
822
834
  return {
823
835
  kind: "refresh-status",
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/lib/queue.ts CHANGED
@@ -794,7 +794,6 @@ export interface TelegramAgentEndRuntimeDeps<
794
794
  options?: { replyToPrompt?: boolean },
795
795
  ) => Promise<void>;
796
796
  getDefaultChatId?: () => number | undefined;
797
- consumeProactiveReplyToMessageId?: (chatId: number) => number | undefined;
798
797
  isProactivePushEnabled?: () => boolean;
799
798
  recordRuntimeEvent?: (
800
799
  category: string,
@@ -839,7 +838,6 @@ export interface TelegramAgentEndHookRuntimeDeps<
839
838
  >["planOutboundReply"];
840
839
  sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
841
840
  getDefaultChatId?: TelegramAgentEndRuntimeDeps<TTurn>["getDefaultChatId"];
842
- consumeProactiveReplyToMessageId?: TelegramAgentEndRuntimeDeps<TTurn>["consumeProactiveReplyToMessageId"];
843
841
  isProactivePushEnabled?: TelegramAgentEndRuntimeDeps<TTurn>["isProactivePushEnabled"];
844
842
  recordRuntimeEvent?: TelegramAgentEndRuntimeDeps<TTurn>["recordRuntimeEvent"];
845
843
  }
@@ -957,7 +955,6 @@ export function createTelegramAgentEndHook<
957
955
  planOutboundReply: deps.planOutboundReply,
958
956
  sendOutboundReplyArtifacts: deps.sendOutboundReplyArtifacts,
959
957
  getDefaultChatId: deps.getDefaultChatId,
960
- consumeProactiveReplyToMessageId: deps.consumeProactiveReplyToMessageId,
961
958
  isProactivePushEnabled: deps.isProactivePushEnabled,
962
959
  recordRuntimeEvent: deps.recordRuntimeEvent,
963
960
  });
@@ -998,14 +995,8 @@ export async function handleTelegramAgentEndRuntime<
998
995
  ) {
999
996
  const defaultChatId = deps.getDefaultChatId?.();
1000
997
  if (defaultChatId !== undefined) {
1001
- const replyToMessageId =
1002
- deps.consumeProactiveReplyToMessageId?.(defaultChatId);
1003
998
  try {
1004
- await deps.sendMarkdownReply(
1005
- defaultChatId,
1006
- replyToMessageId,
1007
- finalText,
1008
- );
999
+ await deps.sendMarkdownReply(defaultChatId, undefined, finalText);
1009
1000
  } catch (error) {
1010
1001
  deps.recordRuntimeEvent?.("proactive-push", error, {
1011
1002
  chatId: defaultChatId,
package/lib/updates.ts CHANGED
@@ -31,12 +31,14 @@ export const TELEGRAM_PRIORITY_REACTIONS = [
31
31
  { id: 11, name: "lightning", emoji: "⚑" },
32
32
  { id: 12, name: "heart", emoji: "❀" },
33
33
  { id: 13, name: "dove", emoji: "πŸ•Š" },
34
+ { id: 14, name: "fire", emoji: "πŸ”₯" },
34
35
  ] as const;
35
36
  export const TELEGRAM_REMOVAL_REACTIONS = [
36
37
  { id: 20, name: "dislike", emoji: "πŸ‘Ž" },
37
38
  { id: 21, name: "ghost", emoji: "πŸ‘»" },
38
39
  { id: 22, name: "broken-heart", emoji: "πŸ’”" },
39
40
  { id: 23, name: "poop", emoji: "πŸ’©" },
41
+ { id: 24, name: "wastebasket", emoji: "πŸ—‘" },
40
42
  ] as const;
41
43
  export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
42
44
  TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for Ο€",
6
6
  "type": "module",