@llblab/pi-telegram 0.16.4 → 0.16.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.
package/AGENTS.md CHANGED
@@ -7,12 +7,15 @@
7
7
  - `Boundary Clarity`: Separate Telegram transport concerns, π integration concerns, rendering behavior, and release/documentation state
8
8
  - `Progressive Enhancement + Graceful Degradation`: Prefer behavior that upgrades automatically when richer runtime context exists, but always preserves a useful fallback path when it does not
9
9
  - `Runtime Safety`: Prefer queue and rendering behavior that fails predictably over clever behavior that can desynchronize the Telegram bridge from π session state
10
+ - `Mobile Companion Boundary`: `pi-telegram` extends a live Pi session for phone use; it is not a remote terminal, PTY supervisor, or process launcher. Do not add terminal-control tricks to make Telegram pretend to be the TUI.
10
11
  - `Pi-Native Extensibility`: `pi-telegram` should inherit π's own extension philosophy. It is not only a Telegram adapter; it should become a small, convenient, composable Telegram shell for π extensions, where new capabilities plug into stable contracts instead of forking polling, transport, or menu ownership.
11
12
 
12
13
  ## 1. Concept
13
14
 
14
15
  `pi-telegram` is a Telegram runtime adapter for π: a session-local operator console that turns a private Telegram DM into a runtime surface for prompt intake, streaming previews, queue management, model/thinking/settings controls, inbound/outbound handler pipelines, voice/buttons, artifacts, and extension callback interop. Treat it as a Telegram membrane around π, not a narrow message pipe.
15
16
 
17
+ The core product loop is mobile continuation: start or supervise work in the terminal, then continue from Telegram while away from the keyboard. Telegram controls should be a safe extension-facing subset of the live session, not a replacement for Pi's interactive TUI.
18
+
16
19
  ## 2. Identity & Naming Contract
17
20
 
18
21
  - `Telegram turn`: One unit of Telegram input processed by π; this may represent one message or a coalesced media group
@@ -67,6 +70,7 @@
67
70
  - Command/menu emoji are fixed UI adornments owned by the `commands` map; do not add a persisted emoji toggle or Settings menu until there is a real setting to own
68
71
  - Telegram `reply_to_message` context is prompt-only and must not affect slash-command parsing
69
72
  - Long-lived timers, pollers, watchers, and deferred queue dispatch must be session-bound and avoid stale live π contexts after session replacement
73
+ - Do not add Telegram commands that imitate Pi interactive session replacement, navigation, or TUI rendering through private internals, ANSI terminal clearing, raw TTY injection, or a shadow `pi` subprocess. Features such as a real Telegram `/new` require a public Pi API that runs the same session-replacement path as the terminal command.
70
74
  - In-flight `/model` switching is limited to Telegram-owned active turns; if a tool call is active, abort is delayed until the tool finishes
71
75
 
72
76
  ## 5.3 Telegram Delivery Semantics
@@ -75,6 +79,7 @@
75
79
  - Real code blocks must stay literal and escaped
76
80
  - `telegram_attach` is the canonical outbound file-delivery path for Telegram-originated requests; outside active Telegram turns it may send immediately to the paired/default chat for explicit local/TUI delivery requests only when this π instance owns `/telegram-connect`. `telegram_message` is the first-class direct Telegram Markdown text tool for local/TUI prompts and is also gated by `/telegram-connect`; neither direct tool replaces normal active-turn replies. It reuses top-level `telegram_button` comments for inline buttons; buttons must be attached to a text message, never sent as standalone actions
77
81
  - Telegram delivery strips top-level HTML comments from preview/final text; column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks are special outbound comments handled after `agent_end` without requiring agent-side transport tool calls, while comments inside code, quotes, lists, or indented examples stay literal
82
+ - Telegram prompt guidance is layered: unconfigured sessions receive no bridge suffix, local/TUI prompts receive only explicit direct-delivery guidance, and Telegram-originated turns receive the full inbound/phone-width/output-action contract
78
83
  - `telegram_voice` and `telegram_button` are not π tools; keep prompts/docs explicit that agents should author markup while voice synthesis provider extensions own TTS/OGG conversion, and pi-telegram owns button routing plus Telegram delivery
79
84
  - Voice reply policy and prompt context are owned by pi-telegram's `telegram.json` `voice.replyMode`: missing/invalid config behaves as `manual` but does not add a `[voice]` prompt-context block; only an explicit valid `voice.replyMode` renders context. Render a single voice field as `[voice] reply mode: manual|mirror|always`, and render multiple fields as a `[voice]` list; place voice context after `[outputs]` when handler output exists, otherwise after `[attachments]`; provider prompt contributions are optional provider-specific additions, not the default policy channel
80
85
  - Optional `telegram.json` `time` may add `[time] YYYY-MM-DD HH:mm:ss <timezone>` to Telegram-originated prompts for wall-clock context. It is hidden by default, uses `time.injectionMode` values `hidden|always|interval`, stores `time.interval` in milliseconds, uses the system timezone, and should render last after `[attachments]`, `[outputs]`, and `[voice]` sections. The Settings row `🕒 Time injection: hidden|always|interval` controls `time.injectionMode` only.
package/BACKLOG.md CHANGED
@@ -1,3 +1,8 @@
1
1
  # Project Backlog
2
2
 
3
- No open work.
3
+ - [ ] Track a public Pi session-replacement hook before adding Telegram `/new`.
4
+ - Context: `pi-telegram` is an extension/mobile companion, not a PTY supervisor. A soft `/new` that mutates session internals or filters context without TUI/runtime parity breaks the product boundary.
5
+ - Requirement: only add Telegram `/new` when Pi exposes a safe public API that invokes the same session-replacement path as terminal `/new`, including lifecycle, active-run handling, and TUI rerender semantics.
6
+ - Rejected for this extension: raw TTY injection, ANSI terminal clearing, private TUI container mutation, or running a shadow `pi` subprocess to control the current session.
7
+
8
+ - [ ] Consider splitting `lib/bindings.ts` if lifecycle/tool wiring grows further.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.16.6: Telegram Review Hardening Hotfix
6
+
7
+ - `[Guest Mode]` Deny `guest_message` updates until the bridge already has a paired Telegram user. Impact: guest mode can no longer become the first pairing surface or trigger guest file/handler processing before explicit DM pairing.
8
+ - `[Lifecycle]` Unref the compaction observer fallback timer when the host timer supports it. Impact: headless or shutdown paths are less likely to linger until the 5-minute safety timeout.
9
+ - `[Shutdown]` Stop polling before clearing active-turn/abort state, keep the abort controller visible until the polling promise settles, and record typing-cleanup failures without skipping polling abort. Impact: session shutdown and polling cleanup ordering is more deterministic.
10
+ - `[Replies]` Scope transport-level reply deduplication by chat id. Impact: equal Telegram message ids in different chats no longer suppress valid reply metadata for each other.
11
+ - `[Buttons]` Consume one-shot `telegram_button` callback actions after the first successful resolve. Impact: repeated taps on an old assistant-authored button no longer enqueue duplicate prompts.
12
+ - `[Buttons]` Centralized Telegram `callback_data` byte-limit guards for generated inline keyboards outside the section helper path. Impact: oversized generated button callbacks fail locally before Telegram rejects the message.
13
+ - `[Diagnostics]` Record `answerCallbackQuery` transport failures in runtime API diagnostics while keeping callback handling non-fatal. Impact: `/telegram-status` can explain failed Telegram callback acknowledgements instead of losing the signal silently.
14
+ - `[Tests]` Added regressions for shutdown during pending control, long-text, and media-group dispatch, for settings menu callbacks persisting voice/time changes to `telegram.json`, for malformed/boundary Markdown rendering, and for runtime outbound delivery retrying a transient Telegram API failure. Impact: high-risk queue/timer/settings/rendering/API paths are pinned at the bridge boundary.
15
+ - `[Docs]` Documented which environment-driven transport defaults should be set before launch because module-load constants intentionally capture them.
16
+ - `[Backlog]` Captured and narrowed the non-blocking 2026-06 review-swarm follow-ups for lifecycle shutdown hardening, reply/callback/button state cleanup, validation coverage, and bindings maintainability.
17
+
18
+ ## 0.16.5: Context-Aware Prompt Guidance Hotfix
19
+
20
+ - `[Prompt Guidance]` Made before-agent-start Telegram guidance context-aware: unconfigured sessions receive no bridge suffix, local/TUI prompts receive only explicit direct-delivery guidance, and Telegram-originated turns keep the full inbound, phone-width, voice, and button contract. Impact: ordinary local replies no longer get raw Telegram action-comment syntax unless the current turn actually comes from Telegram.
21
+ - `[Docs]` Recorded the product boundary that `pi-telegram` is a mobile companion for a live Pi session, not a remote terminal, PTY supervisor, or process launcher. Telegram controls should stay within Pi's extension-facing APIs; true session replacement such as Telegram `/new` should wait for a public Pi hook that preserves interactive runtime and TUI semantics.
22
+
3
23
  ## 0.16.4: Follow-Up And Runtime Mode Hotfix
4
24
 
5
25
  - `[Runtime]` Feature-detect Pi `ctx.mode` and keep `print`/`json` runs passive by blocking polling start/resume in those modes. Impact: CLI/headless sessions can finish local work without inheriting Telegram polling, while `tui`/`rpc` and older Pi runtimes keep existing behavior.
@@ -23,7 +43,7 @@
23
43
 
24
44
  ## 0.16.0: Telegram Extension Commands
25
45
 
26
- - `[API]` Added `registerTelegramCommand()` on the public `/commands` subpath so companion extensions can explicitly provide Telegram-native slash commands without adding workflow-specific commands to core. Built-in bridge commands stay reserved, extension command names must be Bot API safe, duplicate extension names are rejected, commands stay hidden unless `showInMenu` is enabled, visible commands must provide an emoji used in `/start` help and Bot API descriptions, extension-command descriptions are shown in `/start`, visible extension commands are inserted after `/compact` before queue-control commands, prompt-template commands remain separated in `/start`, handler failures are isolated with runtime diagnostics, and routing precedence is built-ins → extension commands → prompt-template aliases. Impact: commands such as fresh-session controls can live in companion extensions while `pi-telegram` remains a lightweight Telegram shell.
46
+ - `[API]` Added `registerTelegramCommand()` on the public `/commands` subpath so companion extensions can explicitly provide Telegram-native slash commands without adding workflow-specific commands to core. Built-in bridge commands stay reserved, extension command names must be Bot API safe, duplicate extension names are rejected, commands stay hidden unless `showInMenu` is enabled, visible commands must provide an emoji used in `/start` help and Bot API descriptions, extension-command descriptions are shown in `/start`, visible extension commands are inserted after `/compact` before queue-control commands, prompt-template commands remain separated in `/start`, handler failures are isolated with runtime diagnostics, and routing precedence is built-ins → extension commands → prompt-template aliases. Impact: workflow-specific controls can live in companion extensions while `pi-telegram` remains a lightweight Telegram shell.
27
47
 
28
48
  ## 0.15.1: Typing Keepalive Cadence
29
49
 
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  `pi-telegram` turns a private Telegram DM into a session-local operator console for π. It admits work, preserves context, streams readable replies, keeps busy sessions usable through queues, lets other extensions share one bot, and turns assistant-authored intent into native Telegram artifacts. It is also a voice-provider platform: companion extensions can supply Telegram transcription and synthesis providers while `pi-telegram` keeps ownership of transport, queueing, and reply policy.
8
8
 
9
+ The product shape is a mobile companion for a live Pi session: start work in the terminal, then continue from Telegram on the couch or outside. It is not a remote terminal, PTY supervisor, or process launcher, and it intentionally avoids pretending to own Pi's interactive TUI.
10
+
9
11
  This repository is an actively maintained fork of [`badlogic/pi-telegram`](https://github.com/badlogic/pi-telegram). It started from upstream commit [`cb34008`](https://github.com/badlogic/pi-telegram/commit/cb34008460b6c1ca036d92322f69d87f626be0fc) and has since diverged substantially.
10
12
 
11
13
  ## Install
@@ -47,7 +49,7 @@ Paste your bot token when prompted. If a bot token is already saved in `~/.pi/ag
47
49
  /telegram-connect
48
50
  ```
49
51
 
50
- The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, inherited child sessions do not start polling unless they take ownership, and `/new` plus same-`cwd` restarts resume automatically. Local queue and reply state stay per Pi instance, so an instance that loses Telegram control still finishes work it already accepted.
52
+ The adapter is session-local: only one π instance polls Telegram at a time. `/telegram-connect` records only external control/polling ownership in `~/.pi/agent/locks.json`; live ownership moves require confirmation, inherited child sessions do not start polling unless they take ownership, and same-`cwd` restarts resume automatically. Local queue and reply state stay per Pi instance, so an instance that loses Telegram control still finishes work it already accepted.
51
53
 
52
54
  ### 4. Pair your Telegram account
53
55
 
@@ -66,12 +68,15 @@ Most day-to-day controls live in the Telegram menu or π commands. A few importa
66
68
  - **Inbound file limit**: `PI_TELEGRAM_INBOUND_FILE_MAX_BYTES` or `TELEGRAM_MAX_FILE_SIZE_BYTES` changes the default 50 MiB Telegram download limit.
67
69
  - **Outbound attachment limit**: `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES` changes the default 50 MiB `telegram_attach` delivery limit.
68
70
 
71
+ Set these variables before launching π. Some transport defaults (notably Telegram temp directory and inbound/outbound byte-limit constants) are intentionally captured when the extension modules load, while setup-token defaults and agent-dir lookups used by config/locks are read through their runtime helpers.
72
+
69
73
  ## Use
70
74
 
71
75
  Once paired, chat with your bot in Telegram. Text, images, files, replies, edits, media groups, and configured handler output are forwarded into π as Telegram-originated turns.
72
76
 
73
77
  What it feels like:
74
78
 
79
+ - Start work in the terminal, walk away, and keep supervising the same live π session from Telegram.
75
80
  - Open `/start` and get a Telegram control panel for the running π session: status, prompt templates, model, thinking, settings, and queue.
76
81
  - Fire off three tasks while π is busy. They become visible queue items instead of terminal noise.
77
82
  - Open Queue from the menu, inspect waiting work, delete stale prompts, or move important work forward.
@@ -82,7 +87,7 @@ What it feels like:
82
87
 
83
88
  ### Telegram controls
84
89
 
85
- Use these inside the Telegram DM with your bot. The main entrypoint is `/start`: it opens the operator menu and exposes many of the important agent controls that normally live in the CLI, adapted for Telegram.
90
+ Use these inside the Telegram DM with your bot. The main entrypoint is `/start`: it opens the operator menu and exposes many of the important agent controls that can be safely adapted through Pi's extension APIs. The bot does not forward arbitrary terminal slash commands or emulate TUI-only session controls.
86
91
 
87
92
  - **`/start`**: Pair the first Telegram user when needed, register bot commands, and open the inline application menu with command help, prompt-template commands, status rows, model controls, thinking controls, settings, and queue controls.
88
93
  - **`/compact`**: Ask for inline confirmation, then start session compaction when the session is idle; Telegram shows the native typing indicator while manual or automatic compaction is running.
@@ -170,6 +175,8 @@ A practical voice setup is simple: Telegram `.ogg` arrives, STT runs locally or
170
175
 
171
176
  Assistant replies can include hidden outbound blocks. `telegram_voice` and `telegram_button` are not π tools; they are assistant-authored HTML comments that the adapter removes from Telegram text and handles after `agent_end`. Recognized blocks must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples. Do not use JSON button specs, inline comments after visible text, or standalone button tool calls; write normal Markdown plus hidden comments, and add visible parent text if buttons would otherwise be the only output.
172
177
 
178
+ Prompt guidance is context-aware: unconfigured sessions receive no Telegram suffix, local/TUI prompts only get explicit direct-delivery guidance, and Telegram-originated turns get the full phone-width output and action-comment contract.
179
+
173
180
  ```md
174
181
  Full technical answer stays readable as text.
175
182
 
@@ -209,7 +216,7 @@ Unknown inline-button callbacks are forwarded to π as `[callback] <data>` when
209
216
 
210
217
  ### Extension Sections
211
218
 
212
- Ordinary pi extensions can register Telegram-native slash commands, structured UI sections, and compact status lines without owning a second polling loop. Slash commands use explicit opt-in registration from `@llblab/pi-telegram/commands`, so workflow-specific commands such as a fresh-session control can live in companion extensions instead of expanding the core bridge command set. UI sections appear in the main Telegram menu and Settings submenu, while status lines allow widgets such as quota indicators to appear beside Status, Usage, Cost, and Context only when relevant to the active model. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
219
+ Ordinary pi extensions can register Telegram-native slash commands, structured UI sections, and compact status lines without owning a second polling loop. Slash commands use explicit opt-in registration from `@llblab/pi-telegram/commands`, so workflow-specific commands can live in companion extensions instead of expanding the core bridge command set. UI sections appear in the main Telegram menu and Settings submenu, while status lines allow widgets such as quota indicators to appear beside Status, Usage, Cost, and Context only when relevant to the active model. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
213
220
 
214
221
  Import `registerTelegramSection()` from `@llblab/pi-telegram/sections` and return a disposer on shutdown. Sections can send interactive messages directly into the chat via `ctx.open()` — confirmation dialogs, approve/deny gates, and multi-step forms live outside the menu hierarchy while callbacks route through the same typed handler. See [`@llblab/pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) for a working reference and the [Extension Sections Standard](./docs/sections.md) for the full contract.
215
222
 
@@ -9,6 +9,8 @@
9
9
  - Stream previews and deliver final π responses back to Telegram.
10
10
  - Provide Telegram-native controls for queueing, model/thinking/settings menus, compaction, abort/stop, prompt templates, reactions, and outbound artifacts.
11
11
 
12
+ The bridge is a mobile companion for a live Pi session, not a remote terminal. It should let an operator start work in the TUI and continue supervising from Telegram, while staying inside Pi's extension-facing contracts.
13
+
12
14
  This document is the architectural map. Focused behavior standards live in sibling docs:
13
15
 
14
16
  - [Public API](./public-api.md) — stable commands, config, package entrypoints, assistant markup, extension APIs, and compatibility boundaries.
@@ -23,6 +25,17 @@ This document is the architectural map. Focused behavior standards live in sibli
23
25
 
24
26
  `index.ts` is the only composition root. It wires live π ports, Telegram Bot API ports, session-local stores, lifecycle hooks, and domain runtimes. Reusable logic lives in flat `/lib/*.ts` domain modules rather than a deep local module tree.
25
27
 
28
+ ### Extension Boundary Vs Supervisor Control
29
+
30
+ `pi-telegram` runs inside the current Pi process as an extension. That gives it safe access to public extension APIs such as aborting work, compacting, sending follow-up prompts, observing lifecycle events, and rendering Telegram-native controls. It does not own the terminal, the interactive-mode chat transcript, or the process lifecycle.
31
+
32
+ Keep this boundary explicit:
33
+
34
+ - Do not use raw TTY injection, ANSI terminal clearing, private TUI container mutation, or a shadow `pi` subprocess to simulate interactive commands.
35
+ - Do not treat Telegram as a generic remote shell for every Pi slash command.
36
+ - Commands that require interactive session replacement or TUI rerendering, such as a true Telegram `/new`, need a public Pi API that invokes the same runtime path as the terminal command.
37
+ - A separate PTY supervisor or daemon could choose to own those risks, but that would be a different product mode rather than this extension's runtime contract.
38
+
26
39
  The repository uses a **Flat Domain DAG**:
27
40
 
28
41
  - Local imports must form a directed acyclic graph.
@@ -104,7 +117,7 @@ Deleting `locks.json` resets runtime ownership without deleting Telegram configu
104
117
 
105
118
  1. Poll updates through `getUpdates`.
106
119
  2. Persist update offsets only after successful handling; repeated handler failures are bounded.
107
- 3. Filter to the paired private user.
120
+ 3. Filter to the paired private user; guest-mode updates require an existing paired user and cannot establish first pairing.
108
121
  4. Dispatch owned callbacks and controls before fallback prompt forwarding.
109
122
  5. Coalesce media groups and likely split long text when needed.
110
123
  6. Download files into `~/.pi/agent/tmp/telegram` with size limits and partial-download cleanup.
@@ -230,7 +243,7 @@ Queue reactions are shortcut controls for waiting turns. Promotion reactions (`
230
243
 
231
244
  When proactive push is enabled and this instance owns the Telegram lock, successful local non-Telegram final replies are sent to the paired chat. Non-owners skip proactive delivery and record a runtime diagnostic. Local prompt text is not mirrored because the bot does not own terminal user messages.
232
245
 
233
- Telegram prompt guidance asks assistants to keep dense mobile-visible text around 37 display cells where possible, because emoji and wide Unicode make raw character counts misleading.
246
+ Telegram prompt guidance is context-aware. Unconfigured sessions receive no bridge suffix. Local/TUI prompts receive only explicit direct-delivery guidance so ordinary terminal replies do not learn raw Telegram action-comment syntax. Telegram-originated turns receive the full inbound context, phone-width output, and native action contract, including the 37-display-cell mobile readability hint.
234
247
 
235
248
  ## In-Flight Model Switching
236
249
 
package/docs/outbound.md CHANGED
@@ -132,12 +132,14 @@ Buttons are built in and do not need a command template because they are pure Te
132
132
 
133
133
  ## Prompt Contract
134
134
 
135
- The extension injects Telegram-specific system prompt guidance so agents know the fast path:
135
+ The extension injects prompt guidance by context:
136
136
 
137
- - Write the full technical answer as normal Markdown.
137
+ - If no bot token is configured, no Telegram bridge suffix is injected.
138
+ - For ordinary local/TUI prompts, the agent only sees explicit direct-delivery guidance: use `telegram_attach` or `telegram_message` when the user asks to send something to Telegram, and otherwise answer locally as normal.
139
+ - For Telegram-originated turns, write the full technical answer as normal Markdown.
138
140
  - Add `telegram_voice` when a Telegram-native voice message is useful; use body text, `text="..."`, or colon shorthand for the text to synthesize. A companion summary is optional, no specific summary format is required.
139
141
  - Add `telegram_button: ...` when label equals prompt, `telegram_button label="..." prompt="..."` for one-line prompts, or `telegram_button label="..."` with a body for multiline prompts. If the reply contains only button/voice comment blocks, add a short visible marker (for example `Choose one:`) before them so Telegram always has a visible parent message for attachment.
140
142
  - For ordinary Telegram-turn replies, do not call transport tools for voice or buttons; the bridge owns delivery, while registered voice synthesis providers own TTS and OGG/Opus conversion. For explicit local/TUI direct sends, `telegram_message` may include top-level `telegram_button` comments in its Markdown text because those buttons are attached to that text message.
141
143
  - Never send buttons without visible parent text. If the answer would contain only hidden comments, add a compact line such as `Choose one:` first.
142
144
 
143
- This keeps the agent focused on semantics and lets the bridge handle low-latency Telegram adaptation.
145
+ This keeps the agent focused on semantics, prevents Telegram action syntax from leaking into normal local replies, and lets the bridge handle low-latency Telegram adaptation.
@@ -53,6 +53,8 @@ Stable commands inside the paired Telegram DM:
53
53
 
54
54
  Hidden compatibility shortcuts may open sections directly: `/help`, `/status`, `/model`, `/thinking`, `/queue`, and `/settings`.
55
55
 
56
+ This command surface is a mobile companion subset, not a raw terminal-command bridge. Commands that depend on Pi's interactive runtime owning session replacement, TUI transcript clearing, or arbitrary slash-command dispatch stay out of the stable Telegram API unless Pi exposes a safe public extension hook for them.
57
+
56
58
  ### Tools and assistant-authored actions
57
59
 
58
60
  - `telegram_attach(paths, chat_id?, caption?)` is the stable artifact delivery tool for generated files. During Telegram turns it queues files for the active reply; outside Telegram turns it sends files directly to the paired/default chat or explicit `chat_id` when this π instance owns `/telegram-connect`.
@@ -60,6 +62,8 @@ Hidden compatibility shortcuts may open sections directly: `/help`, `/status`, `
60
62
  - `telegram_voice` hidden comments request Telegram-native voice delivery.
61
63
  - `telegram_button` hidden comments create inline buttons whose taps enqueue prompts. Use top-level column-zero comments outside code, quotes, lists, and indented examples; do not emit JSON button specs or standalone button actions.
62
64
 
65
+ Prompt guidance is context-aware: local/TUI prompts see only explicit direct-delivery guidance, while Telegram-originated turns receive the full action-comment syntax and phone-width output contract.
66
+
63
67
  See [Outbound Handlers](./outbound.md) for exact markup forms.
64
68
 
65
69
  ## Configuration API
@@ -143,12 +147,12 @@ Import from `@llblab/pi-telegram/commands`. This registers Telegram slash comman
143
147
 
144
148
  ```ts
145
149
  const off = registerTelegramCommand({
146
- name: "new",
147
- description: "Start fresh",
150
+ name: "review",
151
+ description: "Review queued work",
148
152
  showInMenu: true,
149
- emoji: "🆕",
153
+ emoji: "🧩",
150
154
  handler: async (ctx) => {
151
- await ctx.reply("Starting a fresh session is handled by my extension.");
155
+ await ctx.enqueuePrompt(`Review this work: ${ctx.args}`);
152
156
  },
153
157
  });
154
158
  ```
package/lib/bindings.ts CHANGED
@@ -113,7 +113,10 @@ interface TelegramLifecycleBindingDeps {
113
113
  Lifecycle.TelegramLifecycleRegistrationDeps,
114
114
  "onSessionStart" | "onSessionShutdown" | "onModelSelect"
115
115
  >;
116
- configStore: Pick<Config.TelegramConfigStore, "getOutboundHandlers">;
116
+ configStore: Pick<
117
+ Config.TelegramConfigStore,
118
+ "getOutboundHandlers" | "hasBotToken"
119
+ >;
117
120
  abort: Runtime.TelegramRuntimeAbortPort;
118
121
  typing: Runtime.TelegramRuntimeTypingPort;
119
122
  lifecycle: Runtime.TelegramRuntimeLifecyclePort;
@@ -317,6 +320,7 @@ export function registerTelegramLifecycleRuntimeHooks({
317
320
  onSessionCompact: compactionObserver.onSessionCompact,
318
321
  onAgentStart: agentStartWithDedupReset,
319
322
  onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
323
+ isConfigured: configStore.hasBotToken,
320
324
  isProactivePushEnabled,
321
325
  isCurrentOwner: lockOwnershipGuard.ownsContext,
322
326
  }),
package/lib/keyboard.ts CHANGED
@@ -12,3 +12,42 @@ export interface TelegramInlineKeyboardButton {
12
12
  export interface TelegramInlineKeyboardMarkup {
13
13
  inline_keyboard: TelegramInlineKeyboardButton[][];
14
14
  }
15
+
16
+ export const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
17
+
18
+ export function getTelegramCallbackDataByteLength(value: string): number {
19
+ return new TextEncoder().encode(value).byteLength;
20
+ }
21
+
22
+ export function assertTelegramCallbackData(
23
+ callbackData: string,
24
+ context = "Telegram callback_data",
25
+ ): string {
26
+ const byteLength = getTelegramCallbackDataByteLength(callbackData);
27
+ if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
28
+ throw new Error(
29
+ `${context} exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
30
+ );
31
+ }
32
+ return callbackData;
33
+ }
34
+
35
+ export function assertTelegramInlineKeyboardCallbackData(
36
+ replyMarkup: unknown,
37
+ context = "Telegram inline keyboard callback_data",
38
+ ): void {
39
+ if (!replyMarkup || typeof replyMarkup !== "object") return;
40
+ const keyboard = (replyMarkup as { inline_keyboard?: unknown })
41
+ .inline_keyboard;
42
+ if (!Array.isArray(keyboard)) return;
43
+ for (const row of keyboard) {
44
+ if (!Array.isArray(row)) continue;
45
+ for (const button of row) {
46
+ if (!button || typeof button !== "object") continue;
47
+ const callbackData = (button as { callback_data?: unknown })
48
+ .callback_data;
49
+ if (typeof callbackData !== "string") continue;
50
+ assertTelegramCallbackData(callbackData, context);
51
+ }
52
+ }
53
+ }
package/lib/lifecycle.ts CHANGED
@@ -138,6 +138,11 @@ export function createTelegramSessionContextTracker(
138
138
 
139
139
  type TelegramLifecycleTimer = number | ReturnType<typeof setTimeout>;
140
140
 
141
+ function unrefTelegramLifecycleTimer(timer: TelegramLifecycleTimer): void {
142
+ if (!timer || typeof timer !== "object") return;
143
+ if (typeof timer.unref === "function") timer.unref();
144
+ }
145
+
141
146
  export interface TelegramCompactionObserverRuntimeDeps<TContext> {
142
147
  setCompactionInProgress: (inProgress: boolean) => void;
143
148
  updateStatus: (ctx: TContext) => void;
@@ -196,6 +201,7 @@ export function createTelegramCompactionObserverRuntime<TContext>(
196
201
  );
197
202
  requestDispatch();
198
203
  }, timeoutMs);
204
+ unrefTelegramLifecycleTimer(fallbackTimer);
199
205
  },
200
206
  onSessionCompact: (_event, ctx) => {
201
207
  clearFallbackTimer();
@@ -474,6 +474,7 @@ export async function sendQueuedTelegramOutboundAttachments(
474
474
  const method = isPhoto ? "sendPhoto" : "sendDocument";
475
475
  const fieldName = isPhoto ? "photo" : "document";
476
476
  const replyParameters = buildTelegramMultipartReplyParameters(
477
+ turn.chatId,
477
478
  turn.replyToMessageId,
478
479
  );
479
480
  await deps.sendMultipart(
@@ -135,6 +135,7 @@ export function createTelegramButtonActionStore(
135
135
  cleanup(currentTime);
136
136
  const action = actions.get(callbackData);
137
137
  if (!action) return undefined;
138
+ actions.delete(callbackData);
138
139
  return { text: action.text, prompt: action.prompt };
139
140
  },
140
141
  };
@@ -7,6 +7,7 @@
7
7
  import { unlink } from "node:fs/promises";
8
8
  import { basename, extname } from "node:path";
9
9
 
10
+ import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
10
11
  import { getTelegramVoiceSynthesisProviders } from "./voice.ts";
11
12
 
12
13
  export interface TelegramVoiceReplyTurnView {
@@ -125,6 +126,7 @@ export function createTelegramVoiceReplySender<THandler = unknown>(
125
126
  },
126
127
  ): Promise<void> {
127
128
  const voiceFilePath = await ensureTelegramVoiceFileFormat(filePath);
129
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
128
130
  await sendVoiceChatAction(deps, turn.chatId);
129
131
  const replyParameters = buildVoiceReplyParameters(
130
132
  options?.replyToPrompt,
package/lib/polling.ts CHANGED
@@ -187,11 +187,21 @@ export function shouldStartTelegramPolling(
187
187
  export async function stopTelegramPollingRuntime<TContext>(
188
188
  deps: TelegramPollingRuntimeDeps<TContext>,
189
189
  ): Promise<void> {
190
- deps.stopTypingLoop();
191
- deps.getPollingController()?.abort();
192
- deps.setPollingController(undefined);
193
- await deps.getPollingPromise()?.catch(() => undefined);
194
- deps.setPollingPromise(undefined);
190
+ const pollingPromise = deps.getPollingPromise();
191
+ const pollingController = deps.getPollingController();
192
+ try {
193
+ deps.stopTypingLoop();
194
+ } catch (error) {
195
+ deps.recordRuntimeEvent?.("polling", error, { phase: "typing-stop" });
196
+ }
197
+ pollingController?.abort();
198
+ await pollingPromise?.catch(() => undefined);
199
+ if (deps.getPollingPromise() === pollingPromise) {
200
+ deps.setPollingPromise(undefined);
201
+ }
202
+ if (deps.getPollingController() === pollingController) {
203
+ deps.setPollingController(undefined);
204
+ }
195
205
  }
196
206
 
197
207
  function updateTelegramPollingStatusSafely<TContext>(
@@ -224,9 +234,12 @@ export function startTelegramPollingRuntime<TContext>(
224
234
  }
225
235
  const controller = deps.createAbortController?.() ?? new AbortController();
226
236
  deps.setPollingController(controller);
227
- const promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
228
- deps.setPollingPromise(undefined);
229
- deps.setPollingController(undefined);
237
+ let promise: Promise<void>;
238
+ promise = deps.runPollLoop(ctx, controller.signal).finally(() => {
239
+ if (deps.getPollingPromise() === promise) deps.setPollingPromise(undefined);
240
+ if (deps.getPollingController() === controller) {
241
+ deps.setPollingController(undefined);
242
+ }
230
243
  updateTelegramPollingStatusSafely(deps.updateStatus, ctx, {
231
244
  recordRuntimeEvent: deps.recordRuntimeEvent,
232
245
  });
package/lib/preview.ts CHANGED
@@ -241,6 +241,7 @@ export interface TelegramPreviewMessageTransportDeps {
241
241
  sendMessage: (body: TelegramSendMessageBody) => Promise<TelegramSentMessage>;
242
242
  editMessageText: (body: TelegramEditMessageTextBody) => Promise<unknown>;
243
243
  buildReplyParameters?: (
244
+ chatId: number,
244
245
  replyToMessageId: number | undefined,
245
246
  ) => TelegramReplyParameters | undefined;
246
247
  }
@@ -252,7 +253,7 @@ export function createTelegramPreviewMessageTransport(
252
253
  deps.buildReplyParameters ?? buildTelegramReplyParameters;
253
254
  return {
254
255
  sendMessage: (chatId, text, options, replyToMessageId) => {
255
- const replyParameters = getReplyParameters(replyToMessageId);
256
+ const replyParameters = getReplyParameters(chatId, replyToMessageId);
256
257
  return deps.sendMessage({
257
258
  chat_id: chatId,
258
259
  text,
package/lib/prompts.ts CHANGED
@@ -7,11 +7,19 @@
7
7
  import type { BeforeAgentStartEvent } from "./pi.ts";
8
8
  import { TELEGRAM_PREFIX } from "./turns.ts";
9
9
 
10
- const SYSTEM_PROMPT_SUFFIX = `
10
+ const LOCAL_SYSTEM_PROMPT_SUFFIX = `
11
11
 
12
- Telegram bridge extension is active.
12
+ Telegram bridge extension is available.
13
13
 
14
- Inbound context:
14
+ Local/TUI Telegram delivery:
15
+ - Answer ordinary local prompts normally; do not add Telegram action comments unless the user explicitly asks for Telegram delivery.
16
+ - For explicit Telegram file delivery, call \`telegram_attach(local_path)\`. For explicit Telegram text delivery, call \`telegram_message(...)\`.
17
+ - Direct local/TUI Telegram delivery requires this π instance to own \`/telegram-connect\`; if ownership is elsewhere, connect/take over first instead of bypassing the lock.
18
+ `;
19
+
20
+ const TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX = `
21
+
22
+ Telegram-originated turn context:
15
23
  - \`[telegram]\` marks Telegram-originated messages. Suffixes \`|from:user\` (sender) and \`|guest:group\` (guest mode — message from another chat where the bot is not a member) may be present; the bot sees the message as if forwarded from that user/chat.
16
24
  - \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. Suffix \`|from:user\` identifies the original author in guest-mode replies. Use it to resolve references like "this", "it", or "that message"; the actual instruction is before [reply] unless it explicitly asks to act on the quote.
17
25
  - \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains inbound-handler stdout such as transcriptions or extracted text for those attachments.
@@ -27,7 +35,7 @@ Telegram-visible output:
27
35
 
28
36
  Native outbound actions:
29
37
  - Use normal Markdown for visible text. Use top-level column-zero hidden Markdown comments outside code, quotes, and lists only for native actions; the bridge strips them after agent_end and turns them into Telegram-native artifacts/reply_markup. Do not render button JSON, do not invent standalone button tools, and do not call/register transport/TTS/text-to-OGG tools for ordinary Telegram-turn voice/buttons.
30
- - \`telegram_voice\`: text is synthesized by the registered voice synthesis provider and delivered by pi-telegram. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line voice, or \`<!-- telegram_voice: Short summary -->\` for one-line voice with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
38
+ - \`telegram_voice\`: text is synthesized by the registered voice synthesis provider and delivered by pi-telegram. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line text, or \`<!-- telegram_voice: Short summary -->\` for one-line text with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
31
39
  - \`telegram_button\`: callback prompt is routed back as a normal Telegram turn. Use \`<!-- telegram_button: OK -->\` when prompt equals label, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\` for one-line prompts, or body form \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\` for multiline prompts. Do not put button comments inline after visible text, inside code fences, block quotes, lists, or indented examples; those are literal Markdown, not buttons.
32
40
  - If only hidden action comments would remain, add visible parent text like "Choose one:" so Telegram has a message to attach buttons to.
33
41
  `;
@@ -36,19 +44,25 @@ export function buildTelegramBridgeSystemPrompt(options: {
36
44
  prompt: string;
37
45
  systemPrompt: string;
38
46
  telegramPrefix?: string;
39
- systemPromptSuffix: string;
47
+ localSystemPromptSuffix: string;
48
+ telegramTurnSystemPromptSuffix: string;
40
49
  }): { systemPrompt: string } {
41
50
  const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
42
- const suffix = options.prompt.trimStart().startsWith(telegramPrefix)
43
- ? `${options.systemPromptSuffix}\n- The current user message came from Telegram.`
44
- : options.systemPromptSuffix;
45
- return { systemPrompt: options.systemPrompt + suffix };
51
+ const telegramTurn = options.prompt.trimStart().startsWith(telegramPrefix);
52
+ const telegramSuffix = telegramTurn
53
+ ? `${options.telegramTurnSystemPromptSuffix}\n- The current user message came from Telegram.`
54
+ : "";
55
+ return {
56
+ systemPrompt:
57
+ options.systemPrompt + options.localSystemPromptSuffix + telegramSuffix,
58
+ };
46
59
  }
47
60
 
48
61
  export function createTelegramBeforeAgentStartHook(
49
62
  options: {
50
63
  telegramPrefix?: string;
51
- systemPromptSuffix?: string;
64
+ localSystemPromptSuffix?: string;
65
+ telegramTurnSystemPromptSuffix?: string;
52
66
  } = {},
53
67
  ): (event: BeforeAgentStartEvent) => { systemPrompt: string } {
54
68
  return (event) =>
@@ -56,12 +70,17 @@ export function createTelegramBeforeAgentStartHook(
56
70
  prompt: event.prompt,
57
71
  systemPrompt: event.systemPrompt,
58
72
  telegramPrefix: options.telegramPrefix,
59
- systemPromptSuffix: options.systemPromptSuffix ?? SYSTEM_PROMPT_SUFFIX,
73
+ localSystemPromptSuffix:
74
+ options.localSystemPromptSuffix ?? LOCAL_SYSTEM_PROMPT_SUFFIX,
75
+ telegramTurnSystemPromptSuffix:
76
+ options.telegramTurnSystemPromptSuffix ??
77
+ TELEGRAM_TURN_SYSTEM_PROMPT_SUFFIX,
60
78
  });
61
79
  }
62
80
 
63
81
  export interface TelegramProactivePromptHookDeps<TContext> {
64
82
  baseHook?: (event: BeforeAgentStartEvent) => { systemPrompt: string };
83
+ isConfigured: () => boolean;
65
84
  isProactivePushEnabled: () => boolean;
66
85
  isCurrentOwner: (ctx: TContext) => boolean;
67
86
  }
@@ -74,6 +93,7 @@ export function createTelegramProactiveBeforeAgentStartHook<TContext>(
74
93
  ) => Promise<{ systemPrompt: string }> {
75
94
  const baseHook = deps.baseHook ?? createTelegramBeforeAgentStartHook();
76
95
  return async function onBeforeAgentStart(event, ctx) {
96
+ if (!deps.isConfigured()) return { systemPrompt: event.systemPrompt };
77
97
  const result = baseHook(event);
78
98
  if (!deps.isProactivePushEnabled()) return result;
79
99
  if (!deps.isCurrentOwner(ctx)) return result;
package/lib/queue.ts CHANGED
@@ -1439,6 +1439,7 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
1439
1439
  deps: TelegramSessionShutdownRuntimeDeps<TQueueItem>,
1440
1440
  ): Promise<void> {
1441
1441
  deps.unbindDeferredDispatchContext?.();
1442
+ await deps.stopPolling();
1442
1443
  deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
1443
1444
  deps.clearPendingMediaGroups();
1444
1445
  deps.clearModelMenuState();
@@ -1448,7 +1449,6 @@ export async function shutdownTelegramSessionRuntime<TQueueItem>(
1448
1449
  }
1449
1450
  deps.clearActiveTurn();
1450
1451
  deps.clearAbort();
1451
- await deps.stopPolling();
1452
1452
  }
1453
1453
 
1454
1454
  export type TelegramSessionLifecycleRuntimeDeps<
package/lib/replies.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * Owns rendered-message delivery, reply transport wiring, and plain or markdown final replies
5
5
  */
6
6
 
7
+ import { assertTelegramInlineKeyboardCallbackData } from "./keyboard.ts";
7
8
  import type {
8
9
  TelegramReplyParameters,
9
10
  TelegramSentMessage,
@@ -50,25 +51,29 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
50
51
 
51
52
  // --- Transport-level dedup ---
52
53
 
53
- let lastRepliedToMessageId: number | undefined;
54
+ const lastRepliedToMessageIdByChat = new Map<number, number>();
54
55
 
55
56
  export function resetTransportReplyDedup(): void {
56
- lastRepliedToMessageId = undefined;
57
+ lastRepliedToMessageIdByChat.clear();
57
58
  }
58
59
 
59
60
  export function buildTelegramReplyParameters(
61
+ chatId: number,
60
62
  messageId: number | undefined,
61
63
  ): TelegramReplyParameters | undefined {
62
64
  if (messageId === undefined) return undefined;
63
- if (messageId === lastRepliedToMessageId) return undefined;
64
- lastRepliedToMessageId = messageId;
65
+ if (lastRepliedToMessageIdByChat.get(chatId) === messageId) {
66
+ return undefined;
67
+ }
68
+ lastRepliedToMessageIdByChat.set(chatId, messageId);
65
69
  return { message_id: messageId, allow_sending_without_reply: true };
66
70
  }
67
71
 
68
72
  export function buildTelegramMultipartReplyParameters(
73
+ chatId: number,
69
74
  messageId: number | undefined,
70
75
  ): string | undefined {
71
- const parameters = buildTelegramReplyParameters(messageId);
76
+ const parameters = buildTelegramReplyParameters(chatId, messageId);
72
77
  return parameters ? JSON.stringify(parameters) : undefined;
73
78
  }
74
79
 
@@ -178,11 +183,12 @@ export async function sendTelegramRenderedChunks<TReplyMarkup>(
178
183
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
179
184
  options?: { replyMarkup?: TReplyMarkup; replyToMessageId?: number },
180
185
  ): Promise<number | undefined> {
186
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
181
187
  let lastMessageId: number | undefined;
182
188
  for (const [index, chunk] of chunks.entries()) {
183
189
  const replyParameters =
184
190
  index === 0
185
- ? buildTelegramReplyParameters(options?.replyToMessageId)
191
+ ? buildTelegramReplyParameters(chatId, options?.replyToMessageId)
186
192
  : undefined;
187
193
  const sent = await deps.sendMessage({
188
194
  chat_id: chatId,
@@ -204,6 +210,7 @@ export async function editTelegramRenderedMessage<TReplyMarkup>(
204
210
  deps: TelegramReplyDeliveryDeps<TReplyMarkup>,
205
211
  options?: { replyMarkup?: TReplyMarkup },
206
212
  ): Promise<number | undefined> {
213
+ assertTelegramInlineKeyboardCallbackData(options?.replyMarkup);
207
214
  if (chunks.length === 0) return messageId;
208
215
  const [firstChunk, ...remainingChunks] = chunks;
209
216
  await deps.editMessage({
package/lib/sections.ts CHANGED
@@ -4,10 +4,12 @@
4
4
  * Owns section registration, global registry binding, token mapping, main-menu/settings row injection, and section callback dispatch
5
5
  */
6
6
 
7
- import type { TelegramInlineKeyboardMarkup } from "./keyboard.ts";
7
+ import {
8
+ assertTelegramCallbackData,
9
+ type TelegramInlineKeyboardMarkup,
10
+ } from "./keyboard.ts";
8
11
 
9
12
  const SECTION_REGISTRY_KEY = "__piTelegramSectionRegistry__";
10
- const TELEGRAM_CALLBACK_DATA_MAX_BYTES = 64;
11
13
 
12
14
  // --- Core Types ---
13
15
 
@@ -301,10 +303,6 @@ const BACK_NAV_ROW = {
301
303
  text: "⬆️ Back",
302
304
  } as const;
303
305
 
304
- function getUtf8ByteLength(value: string): number {
305
- return new TextEncoder().encode(value).byteLength;
306
- }
307
-
308
306
  function sectionErrorMessage(error: unknown): string {
309
307
  return error instanceof Error ? error.message : String(error);
310
308
  }
@@ -317,13 +315,7 @@ function buildTelegramSectionCallbackData(
317
315
  const data = payload
318
316
  ? `section:${token}:${action}:${payload}`
319
317
  : `section:${token}:${action}`;
320
- const byteLength = getUtf8ByteLength(data);
321
- if (byteLength > TELEGRAM_CALLBACK_DATA_MAX_BYTES) {
322
- throw new Error(
323
- `Telegram section callback_data exceeds ${TELEGRAM_CALLBACK_DATA_MAX_BYTES} bytes (${byteLength}). Use a shorter action/payload or store state behind a compact key.`,
324
- );
325
- }
326
- return data;
318
+ return assertTelegramCallbackData(data, "Telegram section callback_data");
327
319
  }
328
320
 
329
321
  function prependBackRow(
@@ -234,6 +234,14 @@ export interface TelegramFileDownloadOptions {
234
234
  maxFileSizeBytes?: number;
235
235
  }
236
236
 
237
+ export interface TelegramAnswerCallbackQueryOptions {
238
+ recordRuntimeEvent?: (
239
+ kind: "api",
240
+ error: unknown,
241
+ details?: Record<string, unknown>,
242
+ ) => void;
243
+ }
244
+
237
245
  export interface TelegramApiClient {
238
246
  call: <TResponse>(
239
247
  method: string,
@@ -664,6 +672,7 @@ export async function answerTelegramCallbackQuery(
664
672
  botToken: string | undefined,
665
673
  callbackQueryId: string,
666
674
  text?: string,
675
+ options: TelegramAnswerCallbackQueryOptions = {},
667
676
  ): Promise<void> {
668
677
  try {
669
678
  await callTelegram<boolean>(
@@ -673,8 +682,10 @@ export async function answerTelegramCallbackQuery(
673
682
  ? { callback_query_id: callbackQueryId, text }
674
683
  : { callback_query_id: callbackQueryId },
675
684
  );
676
- } catch {
677
- // ignore
685
+ } catch (error) {
686
+ options.recordRuntimeEvent?.("api", error, {
687
+ method: "answerCallbackQuery",
688
+ });
678
689
  }
679
690
  }
680
691
 
@@ -705,7 +716,9 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
705
716
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
706
717
  }): TelegramBridgeApiRuntime {
707
718
  return createTelegramBridgeApiRuntime({
708
- client: createTelegramApiClient(deps.getBotToken),
719
+ client: createTelegramApiClient(deps.getBotToken, {
720
+ recordRuntimeEvent: deps.recordRuntimeEvent,
721
+ }),
709
722
  tempDir: getTelegramApiTempDir(),
710
723
  maxFileSizeBytes: TELEGRAM_INBOUND_FILE_MAX_BYTES,
711
724
  tempFileMaxAgeMs: TELEGRAM_TEMP_FILE_MAX_AGE_MS,
@@ -834,8 +847,14 @@ export function createTelegramBridgeApiRuntime(
834
847
  throw error;
835
848
  }
836
849
  },
837
- answerCallbackQuery: (callbackQueryId, text) => {
838
- return deps.client.answerCallbackQuery(callbackQueryId, text);
850
+ answerCallbackQuery: async (callbackQueryId, text) => {
851
+ try {
852
+ await deps.client.answerCallbackQuery(callbackQueryId, text);
853
+ } catch (error) {
854
+ deps.recordRuntimeEvent("api", error, {
855
+ method: "answerCallbackQuery",
856
+ });
857
+ }
839
858
  },
840
859
  answerGuestQuery: (
841
860
  guestQueryId: string,
@@ -876,6 +895,7 @@ export function createTelegramBridgeApiRuntime(
876
895
  */
877
896
  export function createTelegramApiClient(
878
897
  getBotToken: () => string | undefined,
898
+ options: TelegramAnswerCallbackQueryOptions = {},
879
899
  ): TelegramApiClient {
880
900
  return {
881
901
  call: async (method, body, options) => {
@@ -909,7 +929,12 @@ export function createTelegramApiClient(
909
929
  );
910
930
  },
911
931
  answerCallbackQuery: async (callbackQueryId, text) => {
912
- await answerTelegramCallbackQuery(getBotToken(), callbackQueryId, text);
932
+ await answerTelegramCallbackQuery(
933
+ getBotToken(),
934
+ callbackQueryId,
935
+ text,
936
+ options,
937
+ );
913
938
  },
914
939
  };
915
940
  }
package/lib/updates.ts CHANGED
@@ -406,7 +406,8 @@ export function buildTelegramUpdateExecutionPlan<
406
406
  return {
407
407
  kind: "guest",
408
408
  guestMessage: action.guestMessage,
409
- shouldDeny: action.authorization.kind === "deny",
409
+ // Guest mode is an extension of an already paired bridge, not a pairing surface.
410
+ shouldDeny: action.authorization.kind !== "allow",
410
411
  };
411
412
  }
412
413
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.16.4",
3
+ "version": "0.16.6",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"