@llblab/pi-telegram 0.9.1 → 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 +8 -0
- package/README.md +1 -1
- package/docs/README.md +1 -0
- package/docs/external-update-handlers.md +193 -0
- package/index.ts +4 -1
- package/lib/external-update-handlers.ts +165 -0
- package/lib/menu-queue.ts +3 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
|
|
3
11
|
## 0.9.1: Model Detail Hotfix
|
|
4
12
|
|
|
5
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.
|
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 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
|
|
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
|
|
@@ -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";
|
|
@@ -358,7 +359,9 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
358
359
|
deleteWebhook,
|
|
359
360
|
getUpdates,
|
|
360
361
|
persistConfig: configStore.persist,
|
|
361
|
-
handleUpdate:
|
|
362
|
+
handleUpdate: createTelegramInterceptedHandleUpdate({
|
|
363
|
+
defaultHandle: inboundRouteRuntime.handleUpdate,
|
|
364
|
+
}),
|
|
362
365
|
stopTypingLoop: typing.stop,
|
|
363
366
|
updateStatus,
|
|
364
367
|
recordRuntimeEvent,
|
|
@@ -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-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
|
-
|
|
61
|
-
|
|
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>[],
|