@llblab/pi-telegram 0.9.4 → 0.9.6

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.
@@ -0,0 +1,293 @@
1
+ # Telegram Extension Sections Standard Draft
2
+
3
+ **Status:** Draft. This document is a design note for the upcoming Extension Sections platform and is not an implemented or stable public API yet. Treat all names, shapes, and examples as provisional until the implementation lands.
4
+
5
+ **Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
6
+
7
+ ---
8
+
9
+ Telegram Extension Sections are a proposed registration contract that lets ordinary pi extensions add structured UI sections to the `pi-telegram` inline application menu.
10
+
11
+ The guiding philosophy is pi-native extensibility: `pi-telegram` should inherit π's own model of small composable extensions. The bridge should act as a shared Telegram shell for loaded π extensions, not as a closed one-off bot or a place where every feature must fork Telegram polling and transport.
12
+
13
+ They are not a new extension loader. pi still loads extensions through its normal TypeScript/package system. A loaded extension registers a Telegram section with `pi-telegram`; `pi-telegram` owns bot polling, menu rendering, callback routing, Telegram authorization, and message lifecycle.
14
+
15
+ ## Purpose
16
+
17
+ Use sections when an extension needs a Telegram-native UI surface inside the existing bot shell:
18
+
19
+ - File or project explorers
20
+ - Prompt/session history viewers
21
+ - Tool approval dashboards
22
+ - Runtime status panels
23
+ - Extension settings or diagnostics
24
+ - Human-in-the-loop forms that should not become agent turns
25
+
26
+ Do not use sections for plain agent prompts, one-shot buttons authored by the assistant, or command-template pipelines. Those stay in the normal queue, outbound action comments, inbound/outbound handlers, or command-template domains.
27
+
28
+ ## Identity key
29
+
30
+ Each section has one stable identity key.
31
+
32
+ Use the same identity rules as the Extension Locks Standard:
33
+
34
+ 1. `package.json/name` for npm-style pi packages
35
+ 2. Directory name when the extension entrypoint is `index.ts` but there is no package name
36
+ 3. File basename when the extension is a single file
37
+
38
+ For npm-style package extensions, the canonical value is the `package.json` `name`.
39
+
40
+ Examples:
41
+
42
+ ```text
43
+ extensions/pi-telegram-explorer/package.json name=@llblab/pi-telegram-explorer -> @llblab/pi-telegram-explorer
44
+ extensions/pi-telegram-explorer/index.ts without package.json -> pi-telegram-explorer
45
+ extensions/pi-telegram-explorer.ts -> pi-telegram-explorer
46
+ ```
47
+
48
+ The section `id` is also the owner identity. Do not add a separate `owner` field unless a later concrete need appears.
49
+
50
+ The identity key is used for:
51
+
52
+ - Registry ownership
53
+ - Conflict detection
54
+ - Diagnostics
55
+ - Cleanup
56
+ - Callback routing lookup
57
+ - Future capability policy
58
+
59
+ ## Registration shape
60
+
61
+ Minimum shape:
62
+
63
+ ```ts
64
+ registerTelegramSection({
65
+ id: "@llblab/pi-telegram-explorer",
66
+ label: "🗂 Explorer",
67
+ render(ctx) {
68
+ return {
69
+ text: "<b>Explorer</b>",
70
+ parseMode: "html",
71
+ replyMarkup,
72
+ };
73
+ },
74
+ handleCallback(ctx) {
75
+ return "handled";
76
+ },
77
+ });
78
+ ```
79
+
80
+ Recommended TypeScript shape:
81
+
82
+ ```ts
83
+ type TelegramSectionId = string;
84
+ type TelegramSectionCallbackResult = "handled" | "pass";
85
+
86
+ interface TelegramSectionRegistration {
87
+ id: TelegramSectionId;
88
+ label: string;
89
+ order?: number;
90
+ render: (
91
+ ctx: TelegramSectionRenderContext,
92
+ ) => TelegramSectionView | Promise<TelegramSectionView>;
93
+ handleCallback?: (
94
+ ctx: TelegramSectionCallbackContext,
95
+ ) => TelegramSectionCallbackResult | Promise<TelegramSectionCallbackResult>;
96
+ }
97
+
98
+ interface TelegramSectionView {
99
+ text: string;
100
+ parseMode?: "html" | "plain";
101
+ replyMarkup?: TelegramInlineKeyboardMarkup;
102
+ }
103
+ ```
104
+
105
+ Registration returns a disposer:
106
+
107
+ ```ts
108
+ const unregister = registerTelegramSection(section);
109
+ unregister();
110
+ ```
111
+
112
+ ## Loading model
113
+
114
+ Sections are registered by normal pi extensions:
115
+
116
+ ```ts
117
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
118
+ import { registerTelegramSection } from "@llblab/pi-telegram/lib/extension-sections.ts";
119
+
120
+ export default function (pi: ExtensionAPI) {
121
+ const unregister = registerTelegramSection({
122
+ id: "@llblab/pi-telegram-explorer",
123
+ label: "🗂 Explorer",
124
+ render: async (ctx) => ctx.html("<b>Explorer</b>"),
125
+ });
126
+ pi.on("shutdown", () => unregister());
127
+ }
128
+ ```
129
+
130
+ `pi-telegram` may expose a typed import and a zero-coupling `globalThis` registry. The typed import is the preferred authoring path. The global registry exists only to tolerate load order and package coupling constraints.
131
+
132
+ ## Menu integration
133
+
134
+ `pi-telegram` owns the main Telegram application menu.
135
+
136
+ Registered sections appear as top-level menu rows after built-in core sections unless an `order` value says otherwise.
137
+
138
+ Rules:
139
+
140
+ - `label` must be compact enough for mobile Telegram
141
+ - Built-in sections keep priority over external sections
142
+ - Duplicate `id` registration is rejected or replaces only the same live owner through an explicit disposer path
143
+ - Section errors must not break the whole main menu
144
+ - If a section fails to render, `pi-telegram` should show a compact error row or omit the section and record diagnostics
145
+
146
+ ## Callback routing
147
+
148
+ `pi-telegram` owns section callback transport.
149
+
150
+ A section callback must include a `pi-telegram` owned prefix plus a compact section token that maps back to the full identity key.
151
+
152
+ Conceptual form:
153
+
154
+ ```text
155
+ section:<token>:<action>:<payload>
156
+ ```
157
+
158
+ The token is an implementation detail. The registry maps it to the canonical section `id`. `section:` is reserved in the [Callback Namespace Standard](./callback-namespaces.md); section authors should build callbacks through section context helpers rather than hand-crafting `section:` payloads.
159
+
160
+ Routing order:
161
+
162
+ 1. Telegram update arrives through the single `pi-telegram` poller
163
+ 2. Existing low-level external handlers may observe or consume the update first
164
+ 3. Built-in menu callbacks are handled by built-in domains
165
+ 4. Section callbacks are resolved by token and sent to the registered section
166
+ 5. Unknown callbacks fall back to the existing callback namespace behavior when appropriate
167
+
168
+ Section handlers return:
169
+
170
+ - `"handled"` — callback was handled, do not continue routing
171
+ - `"pass"` — section declines this callback, allow fallback routing
172
+
173
+ Stale callbacks:
174
+
175
+ - Missing section id or token should answer the callback with a short stale/expired notice
176
+ - Missing target state should re-render the section root when possible
177
+ - Section errors should be caught, surfaced as a short callback answer, and recorded in diagnostics
178
+
179
+ ## Runtime ports
180
+
181
+ A section receives a narrow context, not raw `pi-telegram` internals.
182
+
183
+ Initial safe ports:
184
+
185
+ ```ts
186
+ interface TelegramSectionContext {
187
+ sectionId: string;
188
+ chatId: number;
189
+ messageId?: number;
190
+ answerCallback(text?: string): Promise<void>;
191
+ edit(view: TelegramSectionView): Promise<void>;
192
+ open(view: TelegramSectionView): Promise<void>;
193
+ enqueuePrompt(prompt: string): Promise<void>;
194
+ getQueueSnapshot(): TelegramQueueSnapshot;
195
+ getSessionSnapshot?(): TelegramSessionSnapshot;
196
+ }
197
+ ```
198
+
199
+ Filesystem or prompt-history mutation is not part of the baseline. Add capability-specific ports only when the first real extension needs them.
200
+
201
+ ## Security and authorization
202
+
203
+ `pi-telegram` keeps Telegram authorization ownership.
204
+
205
+ Baseline rules:
206
+
207
+ - Section callbacks are accepted only from the paired/authorized Telegram user
208
+ - Sections should not receive unauthorized updates
209
+ - Sections must not start their own Telegram poller
210
+ - Sections must not assume filesystem or session mutation rights
211
+ - Sensitive capabilities should be exposed as explicit typed ports, not by passing raw process or bot clients
212
+
213
+ For filesystem explorers, default to read-only browse and file-send behavior. Deleting, writing, shell execution, or rollback-like mutations require separate explicit capabilities and confirmation UI.
214
+
215
+ ## Diagnostics
216
+
217
+ `pi-telegram` should be able to report registered sections.
218
+
219
+ Minimum diagnostic fields:
220
+
221
+ ```text
222
+ id
223
+ label
224
+ status: active | stale | error
225
+ lastError
226
+ ```
227
+
228
+ The identity key is sufficient as the owner label. Do not add a second owner field.
229
+
230
+ Useful future fields:
231
+
232
+ ```text
233
+ registeredAt
234
+ lastRenderAt
235
+ lastCallbackAt
236
+ callbackCount
237
+ errorCount
238
+ capabilities
239
+ ```
240
+
241
+ Diagnostics should be available through a status/debug surface without cluttering normal Telegram UI.
242
+
243
+ ## Relationship to callback namespaces and external handlers
244
+
245
+ [Callback Namespaces](./callback-namespaces.md) define callback ownership names. [External Handlers](./external-handlers.md) define low-level raw update interception. Extension sections define the structured Telegram UI layer above both.
246
+
247
+ Sections still use namespaced callback data, but `pi-telegram` owns the `section:` prefix and maps compact tokens to canonical section identities. That keeps Telegram's 64-byte callback limit compatible with full npm package names such as `@llblab/pi-telegram-explorer`.
248
+
249
+ Use external handlers when an extension needs direct raw Telegram update access, a custom callback namespace, or out-of-band Promise resolution.
250
+
251
+ Use extension sections when an extension needs a durable menu surface, callback routing, and Telegram UI lifecycle managed by `pi-telegram`.
252
+
253
+ ## Relationship to command templates
254
+
255
+ Command templates execute local commands and pipelines through stdin/stdout.
256
+
257
+ Extension sections do not execute command templates by default. A section may call an extension-owned command or tool internally, but the section standard is a UI registration and callback-routing contract, not a shell execution contract.
258
+
259
+ ## Non-goals
260
+
261
+ - No second Telegram poller
262
+ - No new pi extension loader
263
+ - No generic webview system
264
+ - No default filesystem mutation API
265
+ - No prompt rollback semantics in the base standard
266
+ - No separate owner field while identity key is sufficient
267
+
268
+ ## Evolution path
269
+
270
+ 0.10.0 minimum:
271
+
272
+ - Section registry
273
+ - Main menu integration
274
+ - Section callback routing
275
+ - Narrow runtime ports
276
+ - Diagnostics for registered sections
277
+ - Documentation for extension authors
278
+
279
+ First demo extension candidate:
280
+
281
+ ```text
282
+ @llblab/pi-telegram-explorer
283
+ ```
284
+
285
+ Initial demo scope:
286
+
287
+ - Browse current project tree read-only
288
+ - View compact file previews
289
+ - Send selected files as Telegram documents
290
+ - Browse recent prompt/session snapshots read-only
291
+ - Enqueue a prompt derived from a selected item
292
+
293
+ Defer rollback, filesystem writes, deletes, and broad mutation until the read-only and enqueue-only model is proven.
@@ -1,60 +1,38 @@
1
1
  # External Handlers
2
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.
3
+ `pi-telegram` owns a single `getUpdates` long-poll connection per bot. Other pi extensions cannot open a competing poller against the same bot — the Telegram Bot API uses a per-bot `offset` cursor, and two pollers race each other and lose updates.
7
4
 
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.
5
+ 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.
12
6
 
13
- It is the runtime counterpart to
14
- [Callback Namespaces](./callback-namespaces.md): callback namespaces define
15
- how to share `callback_data` cleanly; external handlers define how to
16
- observe and optionally short-circuit the dispatch of those updates.
7
+ It is the runtime counterpart to [Callback Namespaces](./callback-namespaces.md): callback namespaces define how to share `callback_data` cleanly; external handlers define how to observe and optionally short-circuit the dispatch of those updates.
17
8
 
18
9
  ## When to use it
19
10
 
20
11
  Use it when a layered extension needs to:
21
12
 
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.
13
+ - Resolve out-of-band state, for example a `tool_call` approval Promise, the moment a Telegram callback arrives, rather than waiting for the next agent turn.
14
+ - Suppress `pi-telegram`'s default routing for callbacks owned by the layered extension, so `pi-telegram` does not also forward them as `[callback] <data>` text.
15
+ - Observe arbitrary update types such as messages, edits, channel posts, or reactions without owning the polling connection.
30
16
 
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.
17
+ 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
+
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](./extension-sections.md) contract instead of a raw external handler.
34
20
 
35
21
  ## Constraints
36
22
 
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`.
23
+ - One bot, one pi process, one `getUpdates` poller. This registry does **not** enable running multiple pi instances against the same bot.
24
+ - Interceptors run in the polling loop. They must return quickly; long awaits delay subsequent updates.
25
+ - Interceptor errors are caught and logged silently so polling never breaks. If you need durable error reporting, do it inside your interceptor.
26
+ - The registry lives on `globalThis`. Module instance identity is not required, so layered extensions can reach it without importing `@llblab/pi-telegram`.
46
27
 
47
28
  ## Verdicts
48
29
 
49
30
  Each interceptor returns one of:
50
31
 
51
32
  - `"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.
33
+ - `"pass"` or `void` / `undefined` — `pi-telegram` routes the update normally. Other interceptors registered after this one still run for the same update.
55
34
 
56
- The first interceptor that returns `"consume"` wins; later interceptors are
57
- not called for that update.
35
+ The first interceptor that returns `"consume"` wins; later interceptors are not called for that update.
58
36
 
59
37
  ## Registering an interceptor
60
38
 
@@ -79,18 +57,9 @@ off();
79
57
 
80
58
  ### Zero-coupling globalThis lookup
81
59
 
82
- When the layered extension prefers no `import` from `@llblab/pi-telegram` (so
83
- load order between the two extensions does not matter, and either can be
84
- installed first), it must implement the **full v1 registry contract**, not
85
- just `version` and `add`. pi-telegram's polling runtime calls `dispatch` on
86
- whatever object it finds at `globalThis.__piTelegramExternalHandlerRegistry__`,
87
- so a partial object would silently break the first update.
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.__piTelegramExternalHandlerRegistry__`, so a partial object would silently break the first update.
88
61
 
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.
62
+ 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.
94
63
 
95
64
  ```ts
96
65
  type PiTelegramVerdict =
@@ -151,43 +120,32 @@ const off = getOrCreateRegistry().add((update) => {
151
120
  });
152
121
  ```
153
122
 
154
- The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is
155
- versioned (`version: 1`) and stable across pi-telegram releases; future
156
- breaking changes will use a new schema version and a new key.
123
+ The registry object on `globalThis.__piTelegramExternalHandlerRegistry__` is versioned (`version: 1`) and stable across pi-telegram releases; future breaking changes will use a new schema version and a new key.
157
124
 
158
125
  ## Interaction with built-in routing
159
126
 
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.
127
+ `pi-telegram` invokes registered interceptors 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 interceptor returns `"consume"`, `pi-telegram` skips the rest of routing for that update.
164
128
 
165
129
  This means:
166
130
 
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.
131
+ - Extensions can claim callback namespaces that `pi-telegram` would otherwise forward as `[callback] <data>` text.
132
+ - 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.
174
134
 
175
135
  ## Ownership semantics
176
136
 
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.
137
+ The interceptor 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 poller, for example after ownership is moved to another pi process, interceptors stop receiving updates because no updates are being fetched. They are not unregistered.
182
138
 
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.
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 interceptor registry.
186
140
 
187
141
  ## Not a multiplexer
188
142
 
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.
143
+ This registry does not multiplex one bot across multiple pi processes, and it does not bypass Telegram's single-poller-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
+
145
+ ## Relationship to extension sections
146
+
147
+ External handlers are the raw update primitive. Extension sections are the structured Telegram UI layer above that primitive.
148
+
149
+ Use external 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
+
151
+ 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`.
@@ -71,12 +71,12 @@ Media/file handlers keep the legacy attachment-handler behavior: downloaded file
71
71
 
72
72
  Built-in placeholders for media/file handlers:
73
73
 
74
- | Placeholder | Value |
75
- | ----------- | ---------------------------------------------------------------- |
76
- | `{file}` | Full local path to the downloaded file |
77
- | `{mime}` | MIME type if known |
78
- | `{type}` | Attachment kind such as `voice`, `audio`, `document`, or `photo` |
79
- | `{text}` | Empty string |
74
+ | Placeholder | Value |
75
+ | ----------- | ---------------------------------------------- |
76
+ | `{file}` | Downloaded file path |
77
+ | `{mime}` | MIME type if known |
78
+ | `{type}` | Kind: `voice`, `audio`, `document`, or `photo` |
79
+ | `{text}` | Empty string |
80
80
 
81
81
  If a top-level one-step media handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg as a one-step handler convenience. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
82
82
 
@@ -10,11 +10,11 @@ This document is the local outbound adaptation of the portable [Command Template
10
10
 
11
11
  An outbound handler is selected by `type`. Text replies and assistant markup map to handler types:
12
12
 
13
- | Source | Handler type | Telegram action |
14
- | ----------------- | ------------ | -------------------------------------------------- |
15
- | Final text reply | `text` | Transform text/Markdown before Telegram rendering |
16
- | `telegram_voice` | `voice` | Generate OGG/Opus and call `sendVoice` |
17
- | `telegram_button` | Built-in | Attach an inline keyboard button to the final text |
13
+ | Source | Type | Action |
14
+ | --- | --- | --- |
15
+ | Final text | `text` | Transform before render |
16
+ | `telegram_voice` | `voice` | Generate OGG/Opus |
17
+ | `telegram_button` | Built-in | Attach inline button |
18
18
 
19
19
  Configured command-template handlers provide `template`. A string is one command; an array is ordered composition. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values. The command-template default timeout applies automatically. `output` selects the primary artifact path when the handler produces a file instead of stdout text. Legacy configs may still use `pipe`, but `template: [...]` is the preferred standard shape.
20
20
 
@@ -96,13 +96,13 @@ The bridge strips the comment from Telegram text. On `agent_end`, it maps each `
96
96
 
97
97
  Voice outbound handlers receive these runtime placeholders:
98
98
 
99
- | Placeholder | Value |
100
- | ----------- | -------------------------------------------------------- |
101
- | `{text}` | Voice text from body, `text="..."`, or colon shorthand |
102
- | `{lang}` | Optional markup override such as `lang=ru` |
103
- | `{rate}` | Optional markup override such as `rate=+30%` |
104
- | `{mp3}` | Flat temp artifact path under `~/.pi/agent/tmp/telegram` |
105
- | `{ogg}` | Flat temp artifact path under `~/.pi/agent/tmp/telegram` |
99
+ | Placeholder | Value |
100
+ | --- | --- |
101
+ | `{text}` | Voice text from body, attr, or colon form |
102
+ | `{lang}` | Optional override, e.g. `lang=ru` |
103
+ | `{rate}` | Optional override, e.g. `rate=+30%` |
104
+ | `{mp3}` | Temp MP3 path under agent temp |
105
+ | `{ogg}` | Temp OGG path under agent temp |
106
106
 
107
107
  Temp artifacts use unique flat names such as `<uuid>-voice.mp3` and `<uuid>-voice.ogg`. The bridge does not create per-handler directory trees.
108
108
 
package/index.ts CHANGED
@@ -212,6 +212,7 @@ export default function (pi: Pi.ExtensionAPI) {
212
212
  sendMessage,
213
213
  editMessageText: editTelegramMessageText,
214
214
  canSend: lockOwnershipGuard.ownsCurrentProcess,
215
+ recordRuntimeEvent,
215
216
  ...replyTransport,
216
217
  });
217
218
  const { finalizeMarkdownPreview } =
package/lib/api.ts CHANGED
@@ -12,6 +12,8 @@ import { join, resolve } from "node:path";
12
12
  import { Readable, Transform } from "node:stream";
13
13
  import { pipeline } from "node:stream/promises";
14
14
 
15
+ export const TELEGRAM_API_BASE = "https://api.telegram.org";
16
+
15
17
  export const TELEGRAM_FILE_MAX_BYTES = 50 * 1024 * 1024;
16
18
 
17
19
  export function getTelegramInboundFileByteLimitFromEnv(
@@ -513,7 +515,7 @@ export async function callTelegram<TResponse>(
513
515
  return callTelegramWithRetry(
514
516
  method,
515
517
  async () =>
516
- fetch(`https://api.telegram.org/bot${configuredBotToken}/${method}`, {
518
+ fetch(`${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`, {
517
519
  method: "POST",
518
520
  headers: { "content-type": "application/json" },
519
521
  body: JSON.stringify(body),
@@ -533,7 +535,7 @@ export async function fetchTelegramBotIdentity(
533
535
  fetchImpl: typeof fetch = fetch,
534
536
  ): Promise<TelegramBotIdentityResponse> {
535
537
  const response = await fetchImpl(
536
- `https://api.telegram.org/bot${botToken}/getMe`,
538
+ `${TELEGRAM_API_BASE}/bot${botToken}/getMe`,
537
539
  );
538
540
  return response.json() as Promise<TelegramBotIdentityResponse>;
539
541
  }
@@ -558,7 +560,7 @@ export async function callTelegramMultipart<TResponse>(
558
560
  }
559
561
  form.set(fileField, fileBlob, fileName);
560
562
  return fetch(
561
- `https://api.telegram.org/bot${configuredBotToken}/${method}`,
563
+ `${TELEGRAM_API_BASE}/bot${configuredBotToken}/${method}`,
562
564
  {
563
565
  method: "POST",
564
566
  body: form,
@@ -591,7 +593,7 @@ export async function downloadTelegramFile(
591
593
  `${randomUUID()}-${sanitizeFileName(suggestedName)}`,
592
594
  );
593
595
  const response = await fetch(
594
- `https://api.telegram.org/file/bot${configuredBotToken}/${file.file_path}`,
596
+ `${TELEGRAM_API_BASE}/file/bot${configuredBotToken}/${file.file_path}`,
595
597
  { signal: options?.signal },
596
598
  );
597
599
  if (!response.ok) {
@@ -111,7 +111,6 @@ export function buildProactivePushSettingsText(): string {
111
111
  PROACTIVE_PUSH_SETTINGS_TITLE,
112
112
  "",
113
113
  "Send successful local π task results to Telegram when the bridge is connected.",
114
- "Default: off. Persists until disabled or removed from config.",
115
114
  ].join("\n");
116
115
  }
117
116
 
package/lib/preview.ts CHANGED
@@ -90,6 +90,11 @@ export interface TelegramPreviewRuntimeDeps<
90
90
  options?: { replyMarkup?: TReplyMarkup },
91
91
  ) => Promise<number | undefined>;
92
92
  canSend?: () => boolean;
93
+ recordRuntimeEvent?: (
94
+ category: string,
95
+ error: unknown,
96
+ details?: Record<string, unknown>,
97
+ ) => void;
93
98
  }
94
99
 
95
100
  export interface TelegramPreviewActiveTurn {
@@ -191,6 +196,11 @@ export interface TelegramPreviewControllerDeps<
191
196
  ms: number,
192
197
  ) => ReturnType<typeof setTimeout>;
193
198
  clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
199
+ recordRuntimeEvent?: (
200
+ category: string,
201
+ error: unknown,
202
+ details?: Record<string, unknown>,
203
+ ) => void;
194
204
  }
195
205
 
196
206
  export interface TelegramPreviewController<
@@ -421,6 +431,7 @@ export function createTelegramPreviewController<
421
431
  ),
422
432
  editRenderedMessage: deps.editRenderedMessage,
423
433
  canSend: deps.canSend,
434
+ recordRuntimeEvent: deps.recordRuntimeEvent,
424
435
  });
425
436
  return {
426
437
  getState: () => state,
@@ -645,7 +656,16 @@ export async function flushTelegramPreview<
645
656
  state.flushPromise = (async () => {
646
657
  do {
647
658
  state.flushRequested = false;
648
- await performTelegramPreviewFlush(chatId, state, deps);
659
+ try {
660
+ await performTelegramPreviewFlush(chatId, state, deps);
661
+ } catch (error) {
662
+ deps.recordRuntimeEvent?.("preview", error, {
663
+ phase: "flush",
664
+ chatId,
665
+ messageId: state.messageId,
666
+ });
667
+ break;
668
+ }
649
669
  } while (deps.getState() === state && state.flushRequested);
650
670
  })();
651
671
  try {
@@ -704,13 +724,22 @@ export async function finalizeTelegramMarkdownPreview<
704
724
  await clearTelegramPreview(chatId, deps);
705
725
  return false;
706
726
  }
707
- if (state.mode === "draft") {
708
- await deps.sendRenderedChunks(chatId, chunks, options);
709
- await clearTelegramPreview(chatId, deps);
727
+ try {
728
+ if (state.mode === "draft") {
729
+ await deps.sendRenderedChunks(chatId, chunks, options);
730
+ await clearTelegramPreview(chatId, deps);
731
+ return true;
732
+ }
733
+ if (state.messageId === undefined) return false;
734
+ await deps.editRenderedMessage(chatId, state.messageId, chunks, options);
735
+ deps.setState(undefined);
710
736
  return true;
737
+ } catch (error) {
738
+ deps.recordRuntimeEvent?.("preview", error, {
739
+ phase: "finalize-markdown",
740
+ chatId,
741
+ messageId: state.messageId,
742
+ });
743
+ return false;
711
744
  }
712
- if (state.messageId === undefined) return false;
713
- await deps.editRenderedMessage(chatId, state.messageId, chunks, options);
714
- deps.setState(undefined);
715
- return true;
716
745
  }