@llblab/pi-telegram 0.11.2 → 0.13.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.
- package/AGENTS.md +20 -15
- package/BACKLOG.md +1 -11
- package/CHANGELOG.md +41 -1
- package/README.md +15 -41
- package/api/inbound.ts +14 -0
- package/api/keyboard.ts +10 -0
- package/api/outbound.ts +11 -0
- package/api/sections.ts +17 -0
- package/api/updates.ts +11 -0
- package/api/voice.ts +24 -0
- package/docs/README.md +7 -5
- package/docs/architecture.md +162 -226
- package/docs/callback-namespaces.md +3 -3
- package/docs/command-templates.md +18 -16
- package/docs/{inbound-handlers.md → inbound.md} +14 -11
- package/docs/locks.md +3 -3
- package/docs/{outbound-handlers.md → outbound.md} +14 -11
- package/docs/public-api.md +420 -0
- package/docs/{extension-sections.md → sections.md} +34 -30
- package/docs/ui-style.md +165 -0
- package/docs/{external-handlers.md → updates.md} +33 -31
- package/docs/voice.md +27 -19
- package/index.ts +88 -242
- package/lib/bindings.ts +299 -0
- package/lib/command-templates.ts +249 -60
- package/lib/commands.ts +114 -1
- package/lib/config.ts +44 -4
- package/lib/{inbound-handlers.ts → inbound.ts} +31 -21
- package/lib/lifecycle.ts +41 -6
- package/lib/locks.ts +4 -1
- package/lib/menu-model.ts +3 -3
- package/lib/menu-queue.ts +1 -1
- package/lib/menu-settings.ts +21 -10
- package/lib/menu-status.ts +1 -1
- package/lib/menu.ts +1 -1
- package/lib/outbound-buttons.ts +226 -0
- package/lib/outbound-markup.ts +357 -0
- package/lib/outbound-voice.ts +263 -0
- package/lib/outbound.ts +908 -0
- package/lib/polling.ts +4 -3
- package/lib/preview.ts +2 -2
- package/lib/queue.ts +3 -0
- package/lib/replies.ts +4 -1
- package/lib/routing.ts +44 -3
- package/lib/{extension-sections.ts → sections.ts} +37 -8
- package/lib/status.ts +13 -0
- package/lib/{api.ts → telegram-api.ts} +4 -4
- package/lib/text-groups.ts +3 -2
- package/lib/updates.ts +121 -1
- package/lib/voice.ts +67 -21
- package/package.json +13 -3
- package/lib/external-handlers.ts +0 -166
- package/lib/outbound-handlers.ts +0 -1663
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
Telegram Extension Sections let ordinary pi extensions add structured UI surfaces to the `pi-telegram` inline application menu. The platform mirrors π's own extensibility model: small, composable extensions that plug into a shared shell without owning transport, polling, authorization, or menu lifecycle.
|
|
12
12
|
|
|
13
|
-
`pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second
|
|
13
|
+
`pi-telegram` stays the single bot operator. Extensions register typed sections; the bridge handles rendering, callback routing, token mapping, navigation hierarchy, and diagnostics. No second polling loop, no new loader — just one `registerTelegramSection()` call.
|
|
14
14
|
|
|
15
15
|
## 2. Contract Layers
|
|
16
16
|
|
|
@@ -39,7 +39,7 @@ The `id` is the owner identity. No separate `owner` field. Used for registry own
|
|
|
39
39
|
## 4. Registration Shape
|
|
40
40
|
|
|
41
41
|
```ts
|
|
42
|
-
import { registerTelegramSection } from "@llblab/pi-telegram/
|
|
42
|
+
import { registerTelegramSection } from "@llblab/pi-telegram/sections";
|
|
43
43
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
44
44
|
|
|
45
45
|
export default function (pi: ExtensionAPI) {
|
|
@@ -127,12 +127,12 @@ unregister(); // removes from main menu, settings, and callback routing
|
|
|
127
127
|
|
|
128
128
|
Two paths, same registry:
|
|
129
129
|
|
|
130
|
-
**Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/
|
|
130
|
+
**Typed import (preferred):** Extension imports `registerTelegramSection` from `@llblab/pi-telegram/sections`. The function reads from a `globalThis` registry set by `pi-telegram` at startup. In `0.12.0`, package-private `@llblab/pi-telegram/lib/*.ts` deep imports are no longer exported.
|
|
131
131
|
|
|
132
|
-
**Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use a relative path:
|
|
132
|
+
**Relative import (local):** When the extension cannot resolve `@llblab/pi-telegram` as an npm package, use the public API membrane via a relative path:
|
|
133
133
|
|
|
134
134
|
```ts
|
|
135
|
-
import { registerTelegramSection } from "../pi-telegram/
|
|
135
|
+
import { registerTelegramSection } from "../pi-telegram/api/sections.ts";
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
**GlobalThis bridge (zero-coupling):** `pi-telegram` exposes `__piTelegramSectionRegistry__` on `globalThis`. The typed import is a thin wrapper. Extensions never touch the raw registry.
|
|
@@ -187,18 +187,19 @@ section:<token>:<action>:<payload>
|
|
|
187
187
|
|
|
188
188
|
Example: `section:0:counter:5`
|
|
189
189
|
|
|
190
|
-
The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token.
|
|
190
|
+
The token is an implementation detail. Section authors **never** write `section:` strings manually. Use `ctx.callbackData(action, payload?)` which fills in the correct token and rejects callback data above Telegram's 64-byte limit.
|
|
191
191
|
|
|
192
192
|
### Routing order
|
|
193
193
|
|
|
194
|
-
1. Telegram update arrives through the single `pi-telegram`
|
|
195
|
-
2.
|
|
194
|
+
1. Telegram update arrives through the single `pi-telegram` polling loop
|
|
195
|
+
2. Update handlers observe/consume (raw update interception)
|
|
196
196
|
3. Button action store (`tgbtn:*`)
|
|
197
|
-
4.
|
|
198
|
-
5.
|
|
199
|
-
6.
|
|
200
|
-
7. Section callbacks (`section:*`)
|
|
201
|
-
8.
|
|
197
|
+
4. Compact confirmation callbacks (`compact:*`)
|
|
198
|
+
5. Queue menu callbacks (`queue:*`)
|
|
199
|
+
6. Settings menu callbacks (`settings:*`)
|
|
200
|
+
7. Section callbacks (`section:*`)
|
|
201
|
+
8. Built-in menu callbacks (`menu:*`, `model:*`, `thinking:*`, `status:*`)
|
|
202
|
+
9. Unknown callbacks fall back to `[callback]` prompt text
|
|
202
203
|
|
|
203
204
|
### Handler return values
|
|
204
205
|
|
|
@@ -221,18 +222,18 @@ If a section is unregistered or a token is unknown, the callback is answered wit
|
|
|
221
222
|
|
|
222
223
|
> "This section is no longer available."
|
|
223
224
|
|
|
224
|
-
Section errors are caught and surfaced as popup text. No unhandled exceptions leak to
|
|
225
|
+
Section errors are caught and surfaced as popup text. No unhandled exceptions leak to polling.
|
|
225
226
|
|
|
226
227
|
## 8. Navigation Hierarchy
|
|
227
228
|
|
|
228
|
-
`ctx.edit()`
|
|
229
|
+
`ctx.edit()` automatically prepends a Back row for menu-bound views. The Back target depends on the navigation level:
|
|
229
230
|
|
|
230
231
|
- Section root (from main menu): `⬆️ Main menu` → `menu:back`
|
|
231
232
|
- Section sub-view (`ctx.edit()` in handler): `⬆️ Back` → `section:<token>:open`
|
|
232
233
|
- Settings root (from Settings list): `⬆️ Back` → `settings:list`
|
|
233
234
|
- Settings sub-view (`ctx.edit()` in settings handler): `⬆️ Back` → `settings:list`
|
|
234
235
|
|
|
235
|
-
Section authors do not need to manage the Back button — it is added automatically and deduplicated when already present.
|
|
236
|
+
Section authors do not need to manage the Back button for `ctx.edit()` — it is added automatically and deduplicated when already present. `ctx.open()` sends a standalone chat message and does not prepend a Back row.
|
|
236
237
|
|
|
237
238
|
```
|
|
238
239
|
Main menu
|
|
@@ -256,7 +257,7 @@ interface TelegramSectionContext {
|
|
|
256
257
|
answerCallback(text?: string): Promise<void>;
|
|
257
258
|
/** Edit the current message (auto-prepends Back row) */
|
|
258
259
|
edit(view: TelegramSectionView): Promise<void>;
|
|
259
|
-
/** Send a
|
|
260
|
+
/** Send a standalone chat message without auto-navigation */
|
|
260
261
|
open(view: TelegramSectionView): Promise<void>;
|
|
261
262
|
/** Enqueue a plain-text prompt turn */
|
|
262
263
|
enqueuePrompt(prompt: string): Promise<void>;
|
|
@@ -298,7 +299,7 @@ Context ports are intentionally narrow. Sections **cannot**:
|
|
|
298
299
|
|
|
299
300
|
- Read/write filesystem
|
|
300
301
|
- Access raw process or bot clients
|
|
301
|
-
- Start a second
|
|
302
|
+
- Start a second polling loop
|
|
302
303
|
- Mutate session state
|
|
303
304
|
- Send arbitrary Telegram API calls
|
|
304
305
|
|
|
@@ -320,12 +321,15 @@ handleCallback: async (ctx) => {
|
|
|
320
321
|
text: `<b>Delete ${ctx.payload}?</b>\n\nThis cannot be undone.`,
|
|
321
322
|
parseMode: "html",
|
|
322
323
|
replyMarkup: {
|
|
323
|
-
inline_keyboard: [
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
324
|
+
inline_keyboard: [
|
|
325
|
+
[
|
|
326
|
+
{
|
|
327
|
+
text: "✅ Yes, delete",
|
|
328
|
+
callback_data: ctx.callbackData("confirm-delete", ctx.payload),
|
|
329
|
+
},
|
|
330
|
+
{ text: "❌ Cancel", callback_data: ctx.callbackData("cancel") },
|
|
331
|
+
],
|
|
332
|
+
],
|
|
329
333
|
},
|
|
330
334
|
});
|
|
331
335
|
return "handled";
|
|
@@ -339,7 +343,7 @@ handleCallback: async (ctx) => {
|
|
|
339
343
|
await ctx.deleteMessage();
|
|
340
344
|
return "handled";
|
|
341
345
|
}
|
|
342
|
-
}
|
|
346
|
+
};
|
|
343
347
|
```
|
|
344
348
|
|
|
345
349
|
`ctx.deleteMessage()` removes the dialog from chat after the user makes a choice. Callbacks from chat buttons route through the same `handleCallback` — the same `ctx.callbackData()` works regardless of where the button lives. The extension owns its callback namespace; the bridge owns transport.
|
|
@@ -356,7 +360,7 @@ section:0:settings:open → open settings root
|
|
|
356
360
|
section:0:<action>:<payload> → forwarded to handleCallback
|
|
357
361
|
```
|
|
358
362
|
|
|
359
|
-
`section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
|
|
363
|
+
`section:` is listed in `TELEGRAM_OWNED_CALLBACK_PREFIXES` alongside `compact:`, `menu:`, `model:`, `settings:`, `status:`, `tgbtn:`, `thinking:`, `queue:`. Layered extensions must not use this prefix.
|
|
360
364
|
|
|
361
365
|
### Inline keyboard layout
|
|
362
366
|
|
|
@@ -378,7 +382,7 @@ The platform inherits from π's own extension model:
|
|
|
378
382
|
|
|
379
383
|
- `export default function(pi)` → `registerTelegramSection(section)`
|
|
380
384
|
- `pi.on("shutdown", ...)` → disposer from `registerTelegramSection`
|
|
381
|
-
- Typed imports → typed import from `@llblab/pi-telegram/
|
|
385
|
+
- Typed imports → typed import from `@llblab/pi-telegram/sections`
|
|
382
386
|
- `globalThis` registry → `__piTelegramSectionRegistry__` on `globalThis`
|
|
383
387
|
- Identity from `package.json/name` → same identity rules as Locks Standard
|
|
384
388
|
- Narrow typed context ports → `TelegramSectionContext` / `TelegramSectionCallbackContext`
|
|
@@ -419,7 +423,7 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
|
|
|
419
423
|
|
|
420
424
|
### Non-goals:
|
|
421
425
|
|
|
422
|
-
- No second Telegram
|
|
426
|
+
- No second Telegram polling loop
|
|
423
427
|
- No new pi extension loader
|
|
424
428
|
- No generic webview system
|
|
425
429
|
- No default filesystem mutation API
|
|
@@ -429,8 +433,8 @@ Available programmatically via `getTelegramSectionDiagnostics()`. Section runtim
|
|
|
429
433
|
## 14. Relationship to Other Standards
|
|
430
434
|
|
|
431
435
|
- [Callback Namespaces](./callback-namespaces.md): defines `section:` as pi-telegram-owned prefix. Sections use namespaced callbacks but authors never hand-roll them
|
|
432
|
-
- [
|
|
433
|
-
- [Extension Locks](../docs/locks.md) (
|
|
436
|
+
- [Updates](./updates.md): raw update interception for direct Telegram update access. Sections are the structured UI layer above
|
|
437
|
+
- [Extension Locks](../docs/locks.md) (polling): same identity key rules (`package.json/name` → canonical id)
|
|
434
438
|
- [Command Templates](./command-templates.md): sections do not execute command templates by default. UI registration + callback routing, not shell execution
|
|
435
439
|
|
|
436
440
|
## 15. Demo Extension
|
package/docs/ui-style.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# UI Style Guide
|
|
2
|
+
|
|
3
|
+
Small standard for inline buttons, menu rows, state controls, cards, and confirmation dialogs.
|
|
4
|
+
|
|
5
|
+
## Principles
|
|
6
|
+
|
|
7
|
+
- Keep UI compact and phone-readable.
|
|
8
|
+
- Put emoji where they help scanning, not everywhere.
|
|
9
|
+
- Use one strong indicator for current selection; avoid emoji noise on every option.
|
|
10
|
+
- Match label casing to control role.
|
|
11
|
+
- Prefer minimal, clear configuration UI over exhaustive explanation.
|
|
12
|
+
- Preserve domain-owned callback prefixes and behavior in the owning module.
|
|
13
|
+
|
|
14
|
+
## Action Buttons
|
|
15
|
+
|
|
16
|
+
Action buttons perform an operation.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
|
|
20
|
+
- Use an emoji plus capitalized action text.
|
|
21
|
+
- Prefer direct verb or action noun.
|
|
22
|
+
- Keep labels short.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
|
|
26
|
+
- `🗜 Yes, compact`
|
|
27
|
+
- `❌ No`
|
|
28
|
+
- `🗑 Yes, delete`
|
|
29
|
+
- `☑️ Activate`
|
|
30
|
+
|
|
31
|
+
## State & Navigation Buttons
|
|
32
|
+
|
|
33
|
+
State buttons show the current state and navigate to a submenu or detail rather than performing an operation directly.
|
|
34
|
+
|
|
35
|
+
Rules:
|
|
36
|
+
|
|
37
|
+
- Use an emoji that reflects the current state.
|
|
38
|
+
- Use Capitalized, descriptive state text.
|
|
39
|
+
- Tapping opens a submenu or returns to the parent list.
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
|
|
43
|
+
- `🟢 Active` — model detail, navigates back to model list
|
|
44
|
+
- `📌 Proactive push: On` — settings row, opens the toggle submenu
|
|
45
|
+
- `👄 Voice reply: Mirror` — settings row, opens the option list
|
|
46
|
+
|
|
47
|
+
## Boolean Toggles
|
|
48
|
+
|
|
49
|
+
Boolean settings use a horizontal `On` / `Off` pair, like a checkbox stretched across two buttons.
|
|
50
|
+
|
|
51
|
+
Rules:
|
|
52
|
+
|
|
53
|
+
- Keep the pair in one row: `On` left, `Off` right.
|
|
54
|
+
- Use Capitalized labels.
|
|
55
|
+
- Always show an indicator on both buttons to avoid horizontal label shift.
|
|
56
|
+
- Mark active `On` with `🟢`.
|
|
57
|
+
- Mark active `Off` with `🟡`.
|
|
58
|
+
- Mark the inactive value with `⚫️`.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
|
|
62
|
+
- `🟢 On` / `⚫️ Off`
|
|
63
|
+
- `⚫️ On` / `🟡 Off`
|
|
64
|
+
|
|
65
|
+
## Horizontal Tabs
|
|
66
|
+
|
|
67
|
+
Tabs or small mutually-exclusive scopes use a horizontal row.
|
|
68
|
+
|
|
69
|
+
Rules:
|
|
70
|
+
|
|
71
|
+
- Use Capitalized labels.
|
|
72
|
+
- Always show an indicator on every tab to avoid horizontal label shift.
|
|
73
|
+
- Use active tab color to convey semantics:
|
|
74
|
+
- `🟣` for the default / normal state (All models, Normal priority).
|
|
75
|
+
- `🟡` for an elevated or filtered state (Scoped models, Priority).
|
|
76
|
+
- `🟣` for neutral navigation controls (page picker).
|
|
77
|
+
- Mark inactive tabs with `⚫️`.
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
|
|
81
|
+
- `🟡 Scoped` / `⚫️ All`
|
|
82
|
+
- `⚫️ Priority` / `🟣 Normal`
|
|
83
|
+
- `1` / `🟣 2` / `3`
|
|
84
|
+
|
|
85
|
+
## Vertical Option Lists
|
|
86
|
+
|
|
87
|
+
Vertical option lists choose one value from a potentially longer list, for example model selection, thinking level, voice reply mode, or time injection mode.
|
|
88
|
+
|
|
89
|
+
Rules:
|
|
90
|
+
|
|
91
|
+
- Put each option on its own row.
|
|
92
|
+
- Mark only the current value with `🟢`.
|
|
93
|
+
- Leave non-current values without emoji.
|
|
94
|
+
- Use lowercase labels when the option is a value.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
|
|
98
|
+
- `🟢 mirror`
|
|
99
|
+
- `manual`
|
|
100
|
+
- `always`
|
|
101
|
+
|
|
102
|
+
## Navigation
|
|
103
|
+
|
|
104
|
+
Inline submenu navigation is hierarchical.
|
|
105
|
+
|
|
106
|
+
Rules:
|
|
107
|
+
|
|
108
|
+
- Put the navigation row first.
|
|
109
|
+
- First-level submenus opened from the main inline menu start with `⬆️ Main menu`.
|
|
110
|
+
- Deeper submenus start with `⬆️ Back`.
|
|
111
|
+
- `Main menu` returns to the root inline menu.
|
|
112
|
+
- `Back` returns one level up, never directly to the root unless the parent is the root.
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
|
|
116
|
+
- Main menu → Settings: first row is `⬆️ Main menu`.
|
|
117
|
+
- Settings → Voice reply mode: first row is `⬆️ Back`.
|
|
118
|
+
|
|
119
|
+
## Message Cards
|
|
120
|
+
|
|
121
|
+
Message cards sent by the bot should start with a strong heading.
|
|
122
|
+
|
|
123
|
+
Rules:
|
|
124
|
+
|
|
125
|
+
- Start with a bold heading or, for dialogs, a bold question.
|
|
126
|
+
- Setting detail cards may include an emoji in the heading, then a colon and the current value in `<code>`.
|
|
127
|
+
- Explain what the setting does and what the options mean only as much as needed.
|
|
128
|
+
- Keep descriptions short and clear.
|
|
129
|
+
|
|
130
|
+
Examples:
|
|
131
|
+
|
|
132
|
+
```html
|
|
133
|
+
<b>👄 Voice reply mode:</b> <code>mirror</code>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```html
|
|
137
|
+
<b>Queue</b>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Confirmation Dialogs
|
|
141
|
+
|
|
142
|
+
Confirmation dialogs protect risky or disruptive actions.
|
|
143
|
+
|
|
144
|
+
Rules:
|
|
145
|
+
|
|
146
|
+
- Body text is one bold text-only question.
|
|
147
|
+
- Do not put emoji in the dialog question.
|
|
148
|
+
- Do not add explanatory body copy unless the risk cannot be understood from the question and action labels.
|
|
149
|
+
- Put emoji on the buttons, not in the question.
|
|
150
|
+
- Preserve dialog-specific button order by intent.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
|
|
154
|
+
```html
|
|
155
|
+
<b>Compact session?</b>
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Buttons:
|
|
159
|
+
|
|
160
|
+
- `🗜 Yes, compact`
|
|
161
|
+
- `❌ No`
|
|
162
|
+
|
|
163
|
+
## Callback Ownership
|
|
164
|
+
|
|
165
|
+
UI style does not change callback ownership. Callback prefixes remain owned by their feature domain and must be listed in callback namespace documentation when they become public collision risks.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Updates
|
|
2
2
|
|
|
3
|
-
`
|
|
3
|
+
`updates` owns Telegram update classification, default-routing plans, and the public update-handler registry. The internal `polling` domain owns the actual `getUpdates` loop, offsets, and abort/controller state.
|
|
4
|
+
|
|
5
|
+
`pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing polling connection against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two loops race each other and lose updates.
|
|
4
6
|
|
|
5
7
|
This document describes the registry that lets layered pi extensions running in the same pi process hook into `pi-telegram`'s polling loop and react to inbound Telegram updates **before** `pi-telegram`'s default routing fires.
|
|
6
8
|
|
|
7
|
-
It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly;
|
|
9
|
+
It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; update handlers define how to observe and optionally short-circuit the dispatch of those updates.
|
|
8
10
|
|
|
9
11
|
## When to use it
|
|
10
12
|
|
|
@@ -16,34 +18,34 @@ Use it when a layered extension needs to:
|
|
|
16
18
|
|
|
17
19
|
If the layered extension only needs to read assistant-visible callbacks, the existing `[callback] <data>` fallback documented in [Callback Namespaces](./callback-namespaces.md) is enough.
|
|
18
20
|
|
|
19
|
-
If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./
|
|
21
|
+
If the extension needs a durable top-level Telegram menu section with managed rendering, callback routing, authorization, and diagnostics, use the higher-level [Telegram Extension Sections](./sections.md) contract instead of a raw update handler.
|
|
20
22
|
|
|
21
23
|
## Constraints
|
|
22
24
|
|
|
23
|
-
- One bot, one pi process, one `getUpdates`
|
|
24
|
-
-
|
|
25
|
-
-
|
|
25
|
+
- One bot, one pi process, one `getUpdates` loop. This registry does **not** enable running multiple pi instances against the same bot.
|
|
26
|
+
- Handlers run in the polling loop. They must return quickly; long awaits delay subsequent updates.
|
|
27
|
+
- Handler errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your handler.
|
|
26
28
|
- The registry lives on `globalThis`. Module instance identity is not required, so layered extensions can reach it without importing `@llblab/pi-telegram`.
|
|
27
29
|
|
|
28
30
|
## Verdicts
|
|
29
31
|
|
|
30
|
-
Each
|
|
32
|
+
Each handler returns one of:
|
|
31
33
|
|
|
32
34
|
- `"consume"` — `pi-telegram` skips its default routing for this update.
|
|
33
|
-
- `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other
|
|
35
|
+
- `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other handlers registered after this one still run for the same update.
|
|
34
36
|
|
|
35
|
-
The first
|
|
37
|
+
The first handler that returns `"consume"` wins; later handlers are not called for that update.
|
|
36
38
|
|
|
37
|
-
## Registering
|
|
39
|
+
## Registering a handler
|
|
38
40
|
|
|
39
41
|
Two equivalent paths.
|
|
40
42
|
|
|
41
43
|
### Typed import (recommended when you can depend on `@llblab/pi-telegram`)
|
|
42
44
|
|
|
43
45
|
```ts
|
|
44
|
-
import {
|
|
46
|
+
import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
|
|
45
47
|
|
|
46
|
-
const off =
|
|
48
|
+
const off = registerTelegramUpdateHandler(async (update) => {
|
|
47
49
|
const cb = (update as { callback_query?: { id?: string; data?: string } })
|
|
48
50
|
.callback_query;
|
|
49
51
|
if (!cb?.data?.startsWith("myext:")) return "pass";
|
|
@@ -57,7 +59,7 @@ off();
|
|
|
57
59
|
|
|
58
60
|
### Zero-coupling globalThis lookup
|
|
59
61
|
|
|
60
|
-
When the layered extension prefers no `import` from `@llblab/pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.
|
|
62
|
+
When the layered extension prefers no `import` from `@llblab/pi-telegram`, so load order between the two extensions does not matter and either can be installed first, it must implement the **full v1 registry contract**, not just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on whatever object it finds at `globalThis.__piTelegramUpdateHandlerRegistry__`, so a partial object would silently break the first update.
|
|
61
63
|
|
|
62
64
|
pi-telegram defensively re-creates the registry if the object on `globalThis` is missing `add` or `dispatch`, validated as `version === 1`, `typeof add === "function"`, and `typeof dispatch === "function"`. Handlers registered against a malformed object are dropped — make sure your bootstrap implements all three fields.
|
|
63
65
|
|
|
@@ -67,21 +69,21 @@ type PiTelegramVerdict =
|
|
|
67
69
|
| "pass"
|
|
68
70
|
| void
|
|
69
71
|
| Promise<"consume" | "pass" | void>;
|
|
70
|
-
type
|
|
72
|
+
type PiTelegramUpdateHandler = (update: unknown) => PiTelegramVerdict;
|
|
71
73
|
|
|
72
|
-
interface
|
|
74
|
+
interface PiTelegramUpdateHandlerRegistry {
|
|
73
75
|
readonly version: 1;
|
|
74
|
-
add: (handler:
|
|
76
|
+
add: (handler: PiTelegramUpdateHandler) => () => void;
|
|
75
77
|
// Required: pi-telegram's polling loop calls this on every update.
|
|
76
78
|
dispatch: (update: unknown) => Promise<"consume" | "pass">;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
|
-
const REGISTRY_KEY = "
|
|
81
|
+
const REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
|
|
80
82
|
|
|
81
|
-
function getOrCreateRegistry():
|
|
83
|
+
function getOrCreateRegistry(): PiTelegramUpdateHandlerRegistry {
|
|
82
84
|
const g = globalThis as Record<string, unknown>;
|
|
83
85
|
const existing = g[REGISTRY_KEY] as
|
|
84
|
-
|
|
|
86
|
+
| PiTelegramUpdateHandlerRegistry
|
|
85
87
|
| undefined;
|
|
86
88
|
if (
|
|
87
89
|
existing &&
|
|
@@ -91,8 +93,8 @@ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
|
|
|
91
93
|
) {
|
|
92
94
|
return existing;
|
|
93
95
|
}
|
|
94
|
-
const handlers = new Set<
|
|
95
|
-
const registry:
|
|
96
|
+
const handlers = new Set<PiTelegramUpdateHandler>();
|
|
97
|
+
const registry: PiTelegramUpdateHandlerRegistry = {
|
|
96
98
|
version: 1,
|
|
97
99
|
add(handler) {
|
|
98
100
|
handlers.add(handler);
|
|
@@ -104,7 +106,7 @@ function getOrCreateRegistry(): PiTelegramExternalHandlerRegistry {
|
|
|
104
106
|
const result = await handler(update);
|
|
105
107
|
if (result === "consume") return "consume";
|
|
106
108
|
} catch {
|
|
107
|
-
// Never break polling because of
|
|
109
|
+
// Never break polling because of a handler error.
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
return "pass";
|
|
@@ -120,32 +122,32 @@ const off = getOrCreateRegistry().add((update) => {
|
|
|
120
122
|
});
|
|
121
123
|
```
|
|
122
124
|
|
|
123
|
-
The registry object on `globalThis.
|
|
125
|
+
The registry object on `globalThis.__piTelegramUpdateHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.
|
|
124
126
|
|
|
125
127
|
## Interaction with built-in routing
|
|
126
128
|
|
|
127
|
-
`pi-telegram` invokes registered
|
|
129
|
+
`pi-telegram` invokes registered handlers first, then routes the update through its own handlers: commands, app menu, queue menu, model menu, default prompt routing, and callback namespace fallback. If any handler returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
|
|
128
130
|
|
|
129
131
|
This means:
|
|
130
132
|
|
|
131
133
|
- Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
|
|
132
134
|
- Extensions can observe updates by always returning `"pass"`.
|
|
133
|
-
- Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) unless they are deliberately replacing that behavior.
|
|
135
|
+
- Extensions must not consume updates that belong to `pi-telegram`'s own prefixes (`compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, `section:`) unless they are deliberately replacing that behavior.
|
|
134
136
|
|
|
135
137
|
## Ownership semantics
|
|
136
138
|
|
|
137
|
-
The
|
|
139
|
+
The handler registry is ownership-agnostic and does not interact with the `locks.json` singleton lock documented in [Locks](./locks.md). When the locked polling runtime stops `pi-telegram`'s `getUpdates` loop, for example after ownership is moved to another pi process, handlers stop receiving updates because no updates are being fetched. They are not unregistered.
|
|
138
140
|
|
|
139
|
-
If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the
|
|
141
|
+
If a layered extension needs to react to ownership changes, it should observe `pi-telegram` lifecycle events through the standard pi extension hooks rather than through the handler registry.
|
|
140
142
|
|
|
141
143
|
## Not a multiplexer
|
|
142
144
|
|
|
143
|
-
This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-
|
|
145
|
+
This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-polling-connection-per-bot constraint. To run multiple pi instances on Telegram, give each instance its own bot and its own `~/.pi/agent` directory; the registry is for layered extensions inside **one** pi process.
|
|
144
146
|
|
|
145
147
|
## Relationship to extension sections
|
|
146
148
|
|
|
147
|
-
|
|
149
|
+
Update handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.
|
|
148
150
|
|
|
149
|
-
Use
|
|
151
|
+
Use update handlers for immediate update interception, custom callback namespaces, out-of-band Promise resolution, and update types that should not become a Telegram menu surface.
|
|
150
152
|
|
|
151
153
|
Use extension sections when the desired behavior is a menu-integrated UI: `render(ctx)`, managed callback dispatch, safe runtime ports, stale-callback handling, and diagnostics owned by `pi-telegram`.
|
package/docs/voice.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Voice Integration
|
|
2
2
|
|
|
3
|
-
Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions.
|
|
3
|
+
Voice messages flow through an **inbound transcription → outbound voice reply** pipeline. This document describes the bridge's role in that pipeline; provider-specific mechanics (TTS/STT backends, voice IDs, languages) are owned by voice provider extensions. This is a first-class extension surface: one companion extension can provide STT fallbacks for inbound voice/audio files and TTS fallbacks for outbound Telegram voice replies without owning a second bot polling loop.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
@@ -29,7 +29,7 @@ Inbound handlers match `kind: "voice"` or `mime: "audio/*"` to run a transcripti
|
|
|
29
29
|
|
|
30
30
|
The transcription output becomes the raw text of the prompt.
|
|
31
31
|
|
|
32
|
-
Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/
|
|
32
|
+
Voice provider extensions can also register STT backends with `registerTelegramVoiceTranscriptionProvider()` from `@llblab/pi-telegram/voice`. Inbound command-template handlers and programmatic inbound handlers remain the stronger generic paths and run first; if no matching handler produces output for a voice/audio file, registered transcription providers are tried as fallback in registration order. The first provider that returns non-empty text wins; providers that return `undefined` pass to the next provider, and provider failures are recorded before trying the next provider. This lets a full voice extension provide both TTS and STT without requiring `telegram.json` handler templates, while still preserving operator-configured inbound handlers as the stronger choice.
|
|
33
33
|
|
|
34
34
|
## Voice Reply Policy
|
|
35
35
|
|
|
@@ -67,33 +67,33 @@ The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode
|
|
|
67
67
|
|
|
68
68
|
## Outbound Voice Synthesis Provider Registration
|
|
69
69
|
|
|
70
|
-
Voice synthesis provider extensions
|
|
70
|
+
Voice synthesis provider extensions register themselves through `registerTelegramVoiceSynthesisProvider()`. The bridge only provides the registration seam and the actual delivery to Telegram. **The provider is fully responsible for**:
|
|
71
71
|
|
|
72
72
|
- Text optimisation / speech-style rewriting
|
|
73
73
|
- Adding speech tags (when desired)
|
|
74
74
|
- Running TTS + ffmpeg conversion to OGG/Opus
|
|
75
|
-
- Deciding whether to return `transcriptText` at all
|
|
75
|
+
- Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
|
|
76
76
|
- `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
|
|
77
77
|
|
|
78
78
|
The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`. When a provider returns `transcriptText`, the bridge attaches it as the voice caption.
|
|
79
79
|
|
|
80
80
|
Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
Import provider APIs from `@llblab/pi-telegram/voice`; see the TSDoc on `registerTelegramVoiceSynthesisProvider` and `TelegramVoiceSynthesisProviderResult` there for the exact interface.
|
|
83
83
|
|
|
84
84
|
The provider receives the raw agent text plus optional `{ lang?, rate? }`.
|
|
85
85
|
|
|
86
86
|
It must return one of:
|
|
87
87
|
|
|
88
88
|
- `string` — path to a ready `.ogg` or `.opus` file
|
|
89
|
-
- `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**.
|
|
89
|
+
- `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
|
|
90
90
|
- `undefined` — skip this text block
|
|
91
91
|
|
|
92
92
|
**Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
|
|
93
93
|
|
|
94
94
|
**File format:** Telegram `sendVoice` requires **OGG/Opus** to display the message as a native voice note (waveform, inline playback). MP3 and other formats are accepted by the API but render as regular audio attachments (music note icon, filename visible). **Providers and outbound voice handlers must return `.ogg` or `.opus` files.** Returning non-OGG files causes the bridge to throw and fall back to text delivery.
|
|
95
95
|
|
|
96
|
-
Registration returns a disposer function for cleanup. Extensions should call
|
|
96
|
+
Registration returns a disposer function for cleanup. Stable provider registrations pass a durable `id` in options; omitted ids remain a compatibility path for older providers and receive generated session-local ids. Extensions should call disposers on shutdown or re-register safely on session start when their runtime is recreated.
|
|
97
97
|
|
|
98
98
|
## Outbound Voice Handlers
|
|
99
99
|
|
|
@@ -124,26 +124,34 @@ Priority for outbound voice delivery is: configured `outboundHandlers` with `typ
|
|
|
124
124
|
When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
|
|
125
125
|
|
|
126
126
|
```typescript
|
|
127
|
-
import {
|
|
128
|
-
|
|
129
|
-
registerTelegramVoiceSynthesisProvider
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
127
|
+
import {
|
|
128
|
+
getTelegramVoiceSendTranscript,
|
|
129
|
+
registerTelegramVoiceSynthesisProvider,
|
|
130
|
+
} from "@llblab/pi-telegram/voice";
|
|
131
|
+
|
|
132
|
+
registerTelegramVoiceSynthesisProvider(
|
|
133
|
+
async (text, options) => {
|
|
134
|
+
const rewritten = rewriteWithSpeechTags(text);
|
|
135
|
+
const audioPath = await myTTS(rewritten, { language: options?.lang });
|
|
136
|
+
const sendTranscript = getTelegramVoiceSendTranscript(
|
|
137
|
+
getCurrentTelegramConfigView(),
|
|
138
|
+
);
|
|
139
|
+
return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
|
|
140
|
+
},
|
|
141
|
+
{ id: "my-voice-provider/tts" },
|
|
142
|
+
);
|
|
135
143
|
```
|
|
136
144
|
|
|
137
|
-
The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
|
|
145
|
+
`getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
|
|
138
146
|
|
|
139
147
|
### Surfacing provider diagnostics
|
|
140
148
|
|
|
141
149
|
Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
|
|
142
150
|
|
|
143
151
|
```typescript
|
|
144
|
-
import { recordTelegramRuntimeEvent } from "@llblab/pi-telegram/
|
|
152
|
+
import { recordTelegramRuntimeEvent } from "@llblab/pi-telegram/outbound";
|
|
145
153
|
|
|
146
|
-
recordTelegramRuntimeEvent("
|
|
154
|
+
recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
|
|
147
155
|
phase: "tts",
|
|
148
156
|
text: text.slice(0, 50),
|
|
149
157
|
});
|
|
@@ -155,7 +163,7 @@ recordTelegramRuntimeEvent("xai-voice", new Error("TTS failed"), {
|
|
|
155
163
|
|
|
156
164
|
Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, transcript behavior, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
|
|
157
165
|
|
|
158
|
-
**Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection`
|
|
166
|
+
**Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@llblab/pi-telegram/sections`.
|
|
159
167
|
|
|
160
168
|
## Prompt Guidance
|
|
161
169
|
|