@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 +5 -0
- package/BACKLOG.md +6 -1
- package/CHANGELOG.md +21 -1
- package/README.md +10 -3
- package/docs/architecture.md +15 -2
- package/docs/outbound.md +5 -3
- package/docs/public-api.md +8 -4
- package/lib/bindings.ts +5 -1
- package/lib/keyboard.ts +39 -0
- package/lib/lifecycle.ts +6 -0
- package/lib/outbound-attachments.ts +1 -0
- package/lib/outbound-buttons.ts +1 -0
- package/lib/outbound-voice.ts +2 -0
- package/lib/polling.ts +21 -8
- package/lib/preview.ts +2 -1
- package/lib/prompts.ts +31 -11
- package/lib/queue.ts +1 -1
- package/lib/replies.ts +13 -6
- package/lib/sections.ts +5 -13
- package/lib/telegram-api.ts +31 -6
- package/lib/updates.ts +2 -1
- package/package.json +1 -1
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/docs/architecture.md
CHANGED
|
@@ -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
|
|
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
|
|
135
|
+
The extension injects prompt guidance by context:
|
|
136
136
|
|
|
137
|
-
-
|
|
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.
|
package/docs/public-api.md
CHANGED
|
@@ -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: "
|
|
147
|
-
description: "
|
|
150
|
+
name: "review",
|
|
151
|
+
description: "Review queued work",
|
|
148
152
|
showInMenu: true,
|
|
149
|
-
emoji: "
|
|
153
|
+
emoji: "🧩",
|
|
150
154
|
handler: async (ctx) => {
|
|
151
|
-
await ctx.
|
|
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<
|
|
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(
|
package/lib/outbound-buttons.ts
CHANGED
|
@@ -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
|
};
|
package/lib/outbound-voice.ts
CHANGED
|
@@ -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.
|
|
191
|
-
deps.getPollingController()
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
deps.
|
|
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
|
|
10
|
+
const LOCAL_SYSTEM_PROMPT_SUFFIX = `
|
|
11
11
|
|
|
12
|
-
Telegram bridge extension is
|
|
12
|
+
Telegram bridge extension is available.
|
|
13
13
|
|
|
14
|
-
|
|
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
|
|
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
|
-
|
|
47
|
+
localSystemPromptSuffix: string;
|
|
48
|
+
telegramTurnSystemPromptSuffix: string;
|
|
40
49
|
}): { systemPrompt: string } {
|
|
41
50
|
const telegramPrefix = options.telegramPrefix ?? TELEGRAM_PREFIX;
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
const lastRepliedToMessageIdByChat = new Map<number, number>();
|
|
54
55
|
|
|
55
56
|
export function resetTransportReplyDedup(): void {
|
|
56
|
-
|
|
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 (
|
|
64
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
package/lib/telegram-api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|