@llblab/pi-telegram 0.7.0 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +13 -0
- package/README.md +4 -3
- package/docs/README.md +1 -0
- package/docs/architecture.md +4 -4
- package/docs/callback-namespaces.md +36 -0
- package/index.ts +11 -1
- package/lib/attachment-handlers.ts +6 -2
- package/lib/command-templates.ts +6 -1
- package/lib/commands.ts +61 -73
- package/lib/menu-queue.ts +8 -6
- package/lib/menu-status.ts +4 -4
- package/lib/menu.ts +7 -3
- package/lib/outbound-handlers.ts +3 -1
- package/lib/prompts.ts +1 -0
- package/lib/queue.ts +12 -3
- package/lib/replies.ts +6 -2
- package/lib/routing.ts +43 -3
- package/lib/status.ts +5 -2
- package/lib/text-groups.ts +203 -0
- package/lib/updates.ts +23 -4
- package/package.json +2 -2
package/AGENTS.md
CHANGED
|
@@ -132,8 +132,10 @@ The canonical detailed ownership map lives in [`docs/architecture.md`](./docs/ar
|
|
|
132
132
|
- Command help plus prompt-template commands and status/model/thinking/queue controls are driven through `/start`'s Telegram inline application menu and callback queries; the Queue button shows the queued-item count, model-menu scope/pagination controls stay at the top under Main menu, the model pagination indicator opens a compact page picker, and thinking-menu text stays a compact heading because the current level is marked by button state; `/status`, `/model`, `/thinking`, and `/queue` are hidden compatibility shortcuts
|
|
133
133
|
- Shared inline-keyboard structure belongs to `keyboard`; application-control button labels, callback data, and callback behavior stay in `menu`/`menu-model`/`menu-thinking`/`menu-status`/`menu-queue` while core queue mechanics stay in `queue`
|
|
134
134
|
- Inbound files may become π image inputs or configured attachment-handler text before queueing; outbound files must flow through `telegram_attach`
|
|
135
|
+
- Long Telegram text split recovery belongs to `text-groups`: keep it conservative, short-debounced, same chat/user/message-id contiguous, and gated by near-limit human text so normal rapid follow-ups and slash commands stay separate
|
|
135
136
|
- Inbound attachment handlers and command-backed outbound handlers use command templates as the standard integration contract; built-in outbound buttons use inline keyboards plus callback routing because no external command execution is needed
|
|
136
|
-
- Telegram prompt-template commands are discovered from π slash commands with `source: "prompt"`; π template names are mapped to Bot API-compatible aliases (`fix-tests` → `/fix_tests`), aliases that conflict with built-in bridge commands or hidden shortcuts are not
|
|
137
|
+
- Telegram prompt-template commands are discovered from π slash commands with `source: "prompt"`; π template names are mapped to Bot API-compatible aliases (`fix-tests` → `/fix_tests`), aliases that conflict with built-in bridge commands or hidden shortcuts are not displayed, prompt-template aliases stay out of the Telegram bot command menu, and the bridge expands template files before queueing because extension-originated `sendUserMessage()` bypasses π's interactive template expansion
|
|
138
|
+
- Unknown callback data not owned by pi-telegram prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) may be forwarded as `[callback] <data>` after built-in handlers decline it; external extensions should follow `docs/callback-namespaces.md` and must not poll the same bot independently
|
|
137
139
|
- Command templates stay compact and shell-free: no `command` field, no shell execution, inline defaults are allowed as `{name=default}`, `template` may be a string or an ordered composition array, only `args`/`defaults` inherit into leaves, top-level `timeout` wraps composed sequences, stdout pipes to the next step's stdin by default, and multi-step work should use `template: [...]` rather than provider-specific fields; `pipe` is only a legacy local alias
|
|
138
140
|
- Command-template documentation examples should use portable executable placeholders such as `/path/to/stt` and `/path/to/tts`, not host-local skill paths or machine-specific install locations
|
|
139
141
|
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2: Split Text Coalescing Hotfix
|
|
4
|
+
|
|
5
|
+
- `[Text Coalescing]` Telegram text messages that look like automatic splits of one near-limit human message are now short-debounced and forwarded to π as one prompt, using a conservative 3600-character near-limit threshold. Commands, bot messages, media groups, captions, non-contiguous messages, and normal short follow-ups bypass coalescing. Impact: long pasted logs/prompts are less likely to arrive as separate π turns when Telegram chunks them.
|
|
6
|
+
- `[Runtime Tests]` The media-group runtime regression now waits for the real debounce instead of mixing fake timers with the polling loop, and the reaction-priority runtime test flushes pending microtasks before ending the active turn. Impact: CI should stop failing on timing-only races around delayed dispatch and queued reaction mutations.
|
|
7
|
+
- `[Callback Namespaces]` Current status-screen navigation callbacks now use the canonical `menu:` namespace (`menu:model`, `menu:thinking`, `menu:queue`). `status:` remains reserved as an owned legacy prefix but is no longer emitted by current UI. Impact: new inline menu callbacks align with the unified app-menu model while old `status:` payloads still cannot leak to external fallback handlers.
|
|
8
|
+
- `[Package]` Bumped package metadata to `0.7.2` through npm and kept the lockfile in sync.
|
|
9
|
+
|
|
10
|
+
## 0.7.1: Layered Callback Interop
|
|
11
|
+
|
|
12
|
+
- `[Callback Interop]` Unknown Telegram inline-button callback data that does not belong to pi-telegram-owned prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) is now forwarded to π as `[callback] <data>` after assistant-button, queue-menu, and app-menu handlers decline it. `docs/callback-namespaces.md` defines the shared callback namespace standard for layered extensions. Impact: layered π extensions can namespace and handle their own Telegram inline buttons without polling the same bot or forking pi-telegram.
|
|
13
|
+
- `[Prompt Templates]` Prompt-template aliases stay visible only inside `/start` and are no longer registered in the Telegram bot command menu. Impact: reusable π workflows remain discoverable without making Telegram's global command menu noisy.
|
|
14
|
+
- `[Package]` Bumped package metadata to `0.7.1` through npm and kept the lockfile in sync.
|
|
15
|
+
|
|
3
16
|
## 0.7.0: Unified App Menu & Command Template Hardening
|
|
4
17
|
|
|
5
18
|
- `[Commands]` Visible Telegram bot command menu now exposes `/start`, `/compact`, `/next`, `/continue`, `/abort`, and `/stop`; `/help`, `/status`, `/model`, `/thinking`, and `/queue` remain hidden compatibility shortcuts. `/start`, `/help`, and `/status` open one unified app menu containing command help, status rows, and inline controls. Command emoji are centralized as fixed adornments in the commands domain and reused by matching menu buttons (`🤖` model, `🧠` thinking); `/next` uses `⏩` and `/continue` uses `▶️`. `/continue` enqueues a priority Telegram-owned `continue` prompt instead of forcing the next queued item or requiring π to be idle. Impact: the visible command surface is cleaner while existing operator muscle memory still works and skills can react to queued `continue` prompts.
|
package/README.md
CHANGED
|
@@ -77,14 +77,14 @@ Once paired, simply chat with your bot in Telegram. All text, images, and files
|
|
|
77
77
|
|
|
78
78
|
Use these inside the Telegram DM with your bot:
|
|
79
79
|
|
|
80
|
-
- **`/start`**: Pair the first Telegram user when needed, register
|
|
80
|
+
- **`/start`**: Pair the first Telegram user when needed, register bridge bot commands, and open the inline application menu with command help, available π prompt templates, status rows, and controls.
|
|
81
81
|
- **`/compact`**: Start session compaction (only works when the session is idle).
|
|
82
82
|
- **`/next`**: Dispatch the next queued turn (aborts π first if busy).
|
|
83
83
|
- **`/continue`**: Enqueue a priority `continue` prompt. It waits like normal Telegram work when π is busy and can trigger prompt/skill handling that listens for `continue`.
|
|
84
84
|
- **`/stop`**: Abort the active run and clear all waiting Telegram queue items.
|
|
85
85
|
- **`/abort`**: Abort the active run without touching the queue.
|
|
86
86
|
|
|
87
|
-
Prompt-template commands: π prompt templates are mapped to Telegram-safe aliases (`fix-tests.md` becomes `/fix_tests`) and shown as compact command-only rows between the built-in commands and status rows in `/start
|
|
87
|
+
Prompt-template commands: π prompt templates are mapped to Telegram-safe aliases (`fix-tests.md` becomes `/fix_tests`) and shown as compact command-only rows between the built-in commands and status rows in `/start`. They are not registered in the Telegram bot command menu, keeping the bot menu focused on bridge controls. Sending `/template_name args` from Telegram expands the matching π prompt-template file and queues the expanded prompt like normal Telegram work.
|
|
88
88
|
|
|
89
89
|
Hidden compatibility shortcuts: `/help` and `/status` open the same main application menu, `/model` opens the model section, `/thinking` opens the thinking section, and `/queue` opens the queue section. They are intentionally not shown in the bot command menu.
|
|
90
90
|
|
|
@@ -102,6 +102,7 @@ Run these inside π, not Telegram:
|
|
|
102
102
|
### Queue, Reactions, and Media
|
|
103
103
|
|
|
104
104
|
- If you send more Telegram messages while π is busy, they enter the default prompt queue and are processed in order.
|
|
105
|
+
- Very long text messages that Telegram appears to split automatically are coalesced through a short conservative debounce and forwarded to π as one prompt when the first chunk is near Telegram's text limit, currently using a 3600-character threshold. Commands, bot messages, media groups, and normal short follow-ups are not coalesced.
|
|
105
106
|
- `👍`, `⚡️`, `❤️`, and `🕊` move a waiting prompt into the priority prompt queue, behind control actions but ahead of default prompts. Removing the last priority reaction sends it back to its normal queue position, and adding a priority reaction again gives it a fresh priority position.
|
|
106
107
|
- `👎`, `👻`, `💔`, and `💩` remove a waiting turn from the queue. Telegram Bot API does not expose ordinary DM message-deletion events through the polling path used here, so queue removal is bound to removal reactions.
|
|
107
108
|
- Reactions apply to any waiting Telegram turn, including text, voice, files, images, and media groups. For media groups, a reaction on any message in the group applies to the whole queued turn.
|
|
@@ -190,7 +191,7 @@ List the main risks first.
|
|
|
190
191
|
<!-- telegram_button: OK -->
|
|
191
192
|
```
|
|
192
193
|
|
|
193
|
-
Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
|
|
194
|
+
Button prompts are routed back into the normal Telegram queue as prompt turns. Keep the opening comment unclosed until the body-ending `-->` for body-form buttons. Closed heads must use `prompt="..."` or the colon shorthand to create a button. Unknown inline-button callbacks that do not belong to pi-telegram are forwarded to π as `[callback] <data>` so other extensions can namespace and handle their own Telegram buttons without polling the bot themselves; see the [Callback Namespace Standard](./docs/callback-namespaces.md). Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
|
|
194
195
|
|
|
195
196
|
## Streaming
|
|
196
197
|
|
package/docs/README.md
CHANGED
|
@@ -9,3 +9,4 @@ Living index of project documentation in `/docs`.
|
|
|
9
9
|
- [attachment-handlers.md](./attachment-handlers.md) — Local `pi-telegram` attachment-handler config, placeholders, and fallbacks
|
|
10
10
|
- [outbound-handlers.md](./outbound-handlers.md) — Local `pi-telegram` outbound-handler config, voice/button markup, artifact outputs, and callback routing
|
|
11
11
|
- [locks.md](./locks.md) — Shared `locks.json` standard for singleton extension ownership
|
|
12
|
+
- [callback-namespaces.md](./callback-namespaces.md) — Shared Telegram `callback_data` namespace standard for layered extensions
|
package/docs/architecture.md
CHANGED
|
@@ -30,7 +30,7 @@ Current runtime areas use these ownership boundaries:
|
|
|
30
30
|
| `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
|
|
31
31
|
| `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
|
|
32
32
|
| `updates` / `routing` | Update classification/execution planning, paired authorization, reactions, edits, callbacks, and inbound route composition |
|
|
33
|
-
| `media` / `turns` / `attachment-handlers` | Text/media extraction, media-group debounce, inbound downloads, turn building/editing, image reads, attachment-handler matching/execution/fallback output
|
|
33
|
+
| `media` / `text-groups` / `turns` / `attachment-handlers` | Text/media extraction, media-group debounce, long-text split coalescing, inbound downloads, turn building/editing, image reads, attachment-handler matching/execution/fallback output |
|
|
34
34
|
| `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
|
|
35
35
|
| `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
|
|
36
36
|
| `model` / `menu-model` / `menu-thinking` / `menu-status` / `menu` / `menu-queue` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, model-menu UI, thinking-menu UI, status-menu UI, inline application callback composition, queue-menu UI, slash commands, bot command registration |
|
|
@@ -117,7 +117,7 @@ Dispatch is gated by:
|
|
|
117
117
|
|
|
118
118
|
This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/start` and hidden compatibility shortcuts `/status`, `/model`, `/thinking`, and `/queue` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
|
|
119
119
|
|
|
120
|
-
`/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`,
|
|
120
|
+
`/start` opens the main application menu: visible command help, compact command-only prompt-template rows when π exposes Telegram-compatible prompt-template names, status rows (`Status`, `Usage`, `Cost`, `Context`), and top-level buttons for model, thinking, and queue sections. The Queue button includes the current queued-item count. Hidden compatibility shortcuts `/help`, `/status`, `/model`, `/thinking`, and `/queue` jump directly to their corresponding menu screens. Command emoji come from the `commands` domain map so visible command descriptions and matching menu buttons share one fixed adornment source. Prompt-template commands use a fixed `🧩` marker, map π template names to Telegram-safe aliases such as `fix-tests` → `/fix_tests`, stay visible only inside the `/start` menu, and expand before queueing because `ExtensionAPI.sendUserMessage()` intentionally bypasses π prompt-template expansion for extension-originated messages. Every submenu starts with a top Back row so navigation stays anchored near the original user message above the inline keyboard; model-menu scope and pagination controls sit directly under that top row before model choices, and tapping the pagination indicator opens a compact page picker headed by `<b>Choose a page:</b>`. `menu-model` owns model-menu state, scoped model pages, model callback planning, page-picker rendering, and model-menu rendering while `model` owns core model identity/switching semantics. `menu-thinking` owns thinking-menu text, reply markup, callback handling, and message rendering. `menu-status` owns status-menu payloads, status callback handling, and status-message rendering. `menu-queue` owns queue-menu UI only: queue items are rendered under a compact `<b>Queue:</b>` heading, top-to-bottom in dispatch order, numbered, and marked with `⚡` for priority prompts or `📎` for prompts with attachments. An empty queue renders bold message text plus the top Main menu button, not a disabled empty-state button. Selecting an item opens a submenu that displays the full queued prompt text with Back, priority toggle, and Cancel. If a callback targets an item that has already left the queue, the menu refreshes the list instead of applying a stale mutation.
|
|
121
121
|
|
|
122
122
|
### Abort Behavior
|
|
123
123
|
|
|
@@ -156,11 +156,11 @@ Preferred order:
|
|
|
156
156
|
|
|
157
157
|
Draft streaming can remain as a plain-text fallback path, but rich Telegram previews are driven through editable messages and stable-block snapshot selection.
|
|
158
158
|
|
|
159
|
-
Telegram prompt responses use explicit delivery context to attach outbound text, rich previews, errors, attachment notices, and uploads as Telegram replies to the source prompt when possible. Reply metadata is opt-in per delivery path, uses `reply_parameters` with `allow_sending_without_reply: true`, and is applied only to the first chunk of split long responses; continuation chunks are sent as normal adjacent messages. Media-group turns reply to the turn's representative `replyToMessageId`, not to every source message in the group.
|
|
159
|
+
Telegram prompt responses use explicit delivery context to attach outbound text, rich previews, errors, attachment notices, and uploads as Telegram replies to the source prompt when possible. Reply metadata is opt-in per delivery path, uses `reply_parameters` with `allow_sending_without_reply: true`, and is applied only to the first chunk of split long responses; continuation chunks are sent as normal adjacent messages. Media-group turns reply to the turn's representative `replyToMessageId`, not to every source message in the group. Long text split coalescing is intentionally conservative: only human text messages at or above the 3600-character near-limit threshold open the short debounce window, immediate same-chat/user contiguous text tails join that prompt, and commands, bot messages, captions, media groups, and normal short follow-ups bypass the coalescer.
|
|
160
160
|
|
|
161
161
|
Outbound files are sent only after the active Telegram turn completes, must be staged through the `telegram_attach` tool, are staged atomically per tool call, are checked against a default 50 MiB limit configurable through `PI_TELEGRAM_OUTBOUND_ATTACHMENT_MAX_BYTES` or `TELEGRAM_MAX_ATTACHMENT_SIZE_BYTES`, and use file-backed multipart blobs so large sends do not require preloading whole files into memory.
|
|
162
162
|
|
|
163
|
-
Assistant-authored outbound actions use final-message markup instead of agent tool calls. Preview updates strip closed top-level HTML comments and currently open/partial top-level comment starts before rendering, so users do not see transient metadata even when streaming flushes happen after only `<`, `<!`, or `<!--`. On `agent_end`, the bridge removes top-level comments from the Markdown text reply, but treats column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks specially before delivery; comments inside fenced code, quotes, lists, or indented examples stay literal, including fenced blocks with Markdown-valid indented closing fences. Voice maps to the first matching `outboundHandlers[]` entry with `type: "voice"`, synthesizes body text, `text="..."`, or colon shorthand through command-template execution, and uploads the generated OGG/Opus file via Telegram `sendVoice`; when no outbound voice handler is configured, it silently skips voice delivery. The `template: [...]` form can express TTS plus MP3-to-OGG conversion using configured templates and bridge-provided `{text}`, `{mp3}`, and `{ogg}` placeholders. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values, the default command timeout applies automatically, and each step receives the previous step's stdout on stdin by default, without hard-coded filesystem defaults. Button blocks are built in: each `telegram_button` block becomes one inline-keyboard button on the final text, and callback clicks enqueue the configured prompt text as a normal Telegram prompt turn; the `telegram_button: Label` shorthand uses the same text for label and prompt, `prompt="..."` supports explicit one-line prompts, and body-form buttons use the body as the prompt. This keeps technical Markdown, code, tables, formulas, and numbered lists in the text channel when appropriate while allowing TTS-friendly voice messages and tappable continuations without invoking `telegram_attach` or extra transport tools.
|
|
163
|
+
Assistant-authored outbound actions use final-message markup instead of agent tool calls. Preview updates strip closed top-level HTML comments and currently open/partial top-level comment starts before rendering, so users do not see transient metadata even when streaming flushes happen after only `<`, `<!`, or `<!--`. On `agent_end`, the bridge removes top-level comments from the Markdown text reply, but treats column-zero top-level `<!-- telegram_voice ... -->` and `<!-- telegram_button ... -->` blocks specially before delivery; comments inside fenced code, quotes, lists, or indented examples stay literal, including fenced blocks with Markdown-valid indented closing fences. Voice maps to the first matching `outboundHandlers[]` entry with `type: "voice"`, synthesizes body text, `text="..."`, or colon shorthand through command-template execution, and uploads the generated OGG/Opus file via Telegram `sendVoice`; when no outbound voice handler is configured, it silently skips voice delivery. The `template: [...]` form can express TTS plus MP3-to-OGG conversion using configured templates and bridge-provided `{text}`, `{mp3}`, and `{ogg}` placeholders. Top-level `args` and `defaults` apply to all composed steps unless a step defines private values, the default command timeout applies automatically, and each step receives the previous step's stdout on stdin by default, without hard-coded filesystem defaults. Button blocks are built in: each `telegram_button` block becomes one inline-keyboard button on the final text, and callback clicks enqueue the configured prompt text as a normal Telegram prompt turn; the `telegram_button: Label` shorthand uses the same text for label and prompt, `prompt="..."` supports explicit one-line prompts, and body-form buttons use the body as the prompt. Unknown callback data that does not match pi-telegram-owned prefixes (`tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`) is forwarded to π as `[callback] <data>` after built-in handlers decline it, giving layered extensions a simple namespaced button channel without separate polling; layered callback payloads should follow the [Callback Namespace Standard](./callback-namespaces.md). This keeps technical Markdown, code, tables, formulas, and numbered lists in the text channel when appropriate while allowing TTS-friendly voice messages and tappable continuations without invoking `telegram_attach` or extra transport tools.
|
|
164
164
|
|
|
165
165
|
## Interactive Controls
|
|
166
166
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Callback Namespace Standard
|
|
2
|
+
|
|
3
|
+
Telegram `callback_data` is one bot-wide namespace. Any extension that creates inline buttons for a bot shared with `pi-telegram` must use namespaced callback data.
|
|
4
|
+
|
|
5
|
+
## Format
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
<namespace>:<action>[:<payload>]
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
vividfish:approve:123
|
|
15
|
+
vividfish:deny:123
|
|
16
|
+
myext:page:2
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Rules
|
|
20
|
+
|
|
21
|
+
- Use a stable extension-owned namespace, preferably the package or extension name without scope punctuation.
|
|
22
|
+
- Keep the namespace lowercase ASCII: `a-z`, `0-9`, `_`, `-`.
|
|
23
|
+
- Do not use `pi-telegram` owned prefixes: `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`. Current app navigation uses `menu:`; `status:` remains reserved for legacy/owned status callbacks but is not emitted by current UI.
|
|
24
|
+
- Keep the full `callback_data` within Telegram's 64-byte limit.
|
|
25
|
+
- Put only opaque ids or small enum values in payloads; do not store secrets, full prompts, or large state.
|
|
26
|
+
- Treat callbacks as untrusted input. Validate namespace, action, and payload before executing side effects.
|
|
27
|
+
|
|
28
|
+
## pi-telegram fallback
|
|
29
|
+
|
|
30
|
+
If `pi-telegram` receives callback data that is not owned by its built-in prefixes and no built-in handler consumes it, it forwards the click to π as:
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
[callback] <callback_data>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Layered extensions may intercept that message and handle their own namespace. If no extension handles it, the assistant may see the fallback message and should tell the user the callback was not handled and the environment may be misconfigured.
|
package/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import * as Routing from "./lib/routing.ts";
|
|
|
29
29
|
import * as Setup from "./lib/setup.ts";
|
|
30
30
|
import * as OutboundHandlers from "./lib/outbound-handlers.ts";
|
|
31
31
|
import * as Status from "./lib/status.ts";
|
|
32
|
+
import * as TextGroups from "./lib/text-groups.ts";
|
|
32
33
|
|
|
33
34
|
type ActivePiModel = NonNullable<Pi.ExtensionContext["model"]>;
|
|
34
35
|
type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
|
|
@@ -67,6 +68,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
67
68
|
Api.TelegramMessage,
|
|
68
69
|
Pi.ExtensionContext
|
|
69
70
|
>();
|
|
71
|
+
const textGroupRuntime = TextGroups.createTelegramTextGroupController<
|
|
72
|
+
Api.TelegramMessage,
|
|
73
|
+
Pi.ExtensionContext
|
|
74
|
+
>();
|
|
70
75
|
const telegramQueueStore =
|
|
71
76
|
Queue.createTelegramQueueStore<Pi.ExtensionContext>();
|
|
72
77
|
const deferredQueueDispatchRuntime =
|
|
@@ -277,6 +282,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
277
282
|
bridgeRuntime,
|
|
278
283
|
activeTurnRuntime,
|
|
279
284
|
mediaGroupRuntime,
|
|
285
|
+
textGroupRuntime,
|
|
280
286
|
telegramQueueStore,
|
|
281
287
|
queueMutationRuntime,
|
|
282
288
|
modelMenuRuntime,
|
|
@@ -297,6 +303,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
297
303
|
getThinkingLevel,
|
|
298
304
|
setThinkingLevel,
|
|
299
305
|
setModel,
|
|
306
|
+
sendUserMessage,
|
|
300
307
|
isIdle,
|
|
301
308
|
hasPendingMessages,
|
|
302
309
|
compact,
|
|
@@ -341,7 +348,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
341
348
|
prepareTempDir,
|
|
342
349
|
updateStatus,
|
|
343
350
|
unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
|
|
344
|
-
clearPendingMediaGroups:
|
|
351
|
+
clearPendingMediaGroups: TextGroups.createTelegramGroupedInputClearer({
|
|
352
|
+
clearMediaGroups: mediaGroupRuntime.clear,
|
|
353
|
+
clearTextGroups: textGroupRuntime.clear,
|
|
354
|
+
}),
|
|
345
355
|
clearModelMenuState: modelMenuRuntime.clear,
|
|
346
356
|
getActiveTurnChatId: activeTurnRuntime.getChatId,
|
|
347
357
|
clearPreview: previewRuntime.clear,
|
|
@@ -259,7 +259,9 @@ function getTelegramAttachmentCompositionStepTimeout(
|
|
|
259
259
|
startedAt,
|
|
260
260
|
);
|
|
261
261
|
const stepTimeout = getTelegramAttachmentHandlerConfiguredTimeout(step);
|
|
262
|
-
return stepTimeout === undefined
|
|
262
|
+
return stepTimeout === undefined
|
|
263
|
+
? remaining
|
|
264
|
+
: Math.min(stepTimeout, remaining);
|
|
263
265
|
}
|
|
264
266
|
|
|
265
267
|
function getTelegramAttachmentHandlerKind(
|
|
@@ -300,7 +302,9 @@ async function executeTelegramAttachmentHandlerInvocation(
|
|
|
300
302
|
const result = await deps.execCommand(invocation.command, invocation.args, {
|
|
301
303
|
cwd,
|
|
302
304
|
timeout,
|
|
303
|
-
...(typeof handler === "object" && handler.retry !== undefined
|
|
305
|
+
...(typeof handler === "object" && handler.retry !== undefined
|
|
306
|
+
? { retry: handler.retry }
|
|
307
|
+
: {}),
|
|
304
308
|
...(stdin !== undefined ? { stdin } : {}),
|
|
305
309
|
});
|
|
306
310
|
if (result.code !== 0)
|
package/lib/command-templates.ts
CHANGED
|
@@ -210,7 +210,12 @@ export async function execCommandTemplate(
|
|
|
210
210
|
options: CommandTemplateExecOptions = {},
|
|
211
211
|
): Promise<CommandTemplateExecResult> {
|
|
212
212
|
const maxAttempts = options.retry ?? 1;
|
|
213
|
-
let lastResult: CommandTemplateExecResult = {
|
|
213
|
+
let lastResult: CommandTemplateExecResult = {
|
|
214
|
+
stdout: "",
|
|
215
|
+
stderr: "",
|
|
216
|
+
code: 1,
|
|
217
|
+
killed: false,
|
|
218
|
+
};
|
|
214
219
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
215
220
|
const result = await execCommandTemplateOnce(command, args, options);
|
|
216
221
|
if (result.code === 0) return result;
|
package/lib/commands.ts
CHANGED
|
@@ -61,84 +61,61 @@ function formatTelegramBotCommandDescription(
|
|
|
61
61
|
return `${formatTelegramCommandEmojiPrefix(command)}${description}`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] =
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
64
|
+
export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinition[] =
|
|
65
|
+
[
|
|
66
|
+
{
|
|
67
|
+
command: "start",
|
|
68
|
+
description: formatTelegramBotCommandDescription(
|
|
69
|
+
"start",
|
|
70
|
+
"Open menu / Pair bridge",
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
command: "compact",
|
|
75
|
+
description: formatTelegramBotCommandDescription(
|
|
76
|
+
"compact",
|
|
77
|
+
"Compact current session",
|
|
78
|
+
),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
command: "next",
|
|
82
|
+
description: formatTelegramBotCommandDescription(
|
|
83
|
+
"next",
|
|
84
|
+
"Force next turn",
|
|
85
|
+
),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
command: "continue",
|
|
89
|
+
description: formatTelegramBotCommandDescription(
|
|
90
|
+
"continue",
|
|
91
|
+
"Queue continue prompt",
|
|
92
|
+
),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
command: "abort",
|
|
96
|
+
description: formatTelegramBotCommandDescription("abort", "Abort π"),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
command: "stop",
|
|
100
|
+
description: formatTelegramBotCommandDescription(
|
|
101
|
+
"stop",
|
|
102
|
+
"Abort π & Clear queue",
|
|
103
|
+
),
|
|
104
|
+
},
|
|
105
|
+
];
|
|
105
106
|
|
|
106
107
|
export const TELEGRAM_BOT_COMMANDS = TELEGRAM_BUILTIN_BOT_COMMANDS;
|
|
107
108
|
|
|
108
|
-
const TELEGRAM_MAX_BOT_COMMANDS = 100;
|
|
109
|
-
const TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT = 256;
|
|
110
|
-
|
|
111
|
-
function truncateTelegramBotCommandDescription(description: string): string {
|
|
112
|
-
if (description.length <= TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT) return description;
|
|
113
|
-
return `${description.slice(0, TELEGRAM_BOT_COMMAND_DESCRIPTION_LIMIT - 1)}…`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function buildTelegramBotCommands(
|
|
117
|
-
promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
|
|
118
|
-
): TelegramBotCommandDefinition[] {
|
|
119
|
-
const remainingSlots = TELEGRAM_MAX_BOT_COMMANDS - TELEGRAM_BUILTIN_BOT_COMMANDS.length;
|
|
120
|
-
const templateCommands = promptTemplates.slice(0, remainingSlots).map((template) => ({
|
|
121
|
-
command: template.command,
|
|
122
|
-
description: truncateTelegramBotCommandDescription(
|
|
123
|
-
`🧩 ${template.description?.trim() || "Prompt template"}`,
|
|
124
|
-
),
|
|
125
|
-
}));
|
|
126
|
-
return [...TELEGRAM_BUILTIN_BOT_COMMANDS, ...templateCommands];
|
|
127
|
-
}
|
|
128
|
-
|
|
129
109
|
export interface TelegramBotCommandRegistrationDeps {
|
|
130
110
|
setMyCommands: (
|
|
131
111
|
commands: readonly TelegramBotCommandDefinition[],
|
|
132
112
|
) => Promise<unknown>;
|
|
133
|
-
getPromptTemplateCommands?: () => readonly TelegramPromptTemplateMenuCommand[];
|
|
134
113
|
}
|
|
135
114
|
|
|
136
115
|
export async function registerTelegramBotCommands(
|
|
137
116
|
deps: TelegramBotCommandRegistrationDeps,
|
|
138
117
|
): Promise<void> {
|
|
139
|
-
await deps.setMyCommands(
|
|
140
|
-
buildTelegramBotCommands(deps.getPromptTemplateCommands?.()),
|
|
141
|
-
);
|
|
118
|
+
await deps.setMyCommands(TELEGRAM_BOT_COMMANDS);
|
|
142
119
|
}
|
|
143
120
|
|
|
144
121
|
export function createTelegramBotCommandRegistrar(
|
|
@@ -267,7 +244,10 @@ const TELEGRAM_RESERVED_COMMAND_NAME_SET = new Set<string>(
|
|
|
267
244
|
export function isTelegramReservedCommandName(
|
|
268
245
|
commandName: string | undefined,
|
|
269
246
|
): commandName is TelegramReservedCommandName {
|
|
270
|
-
return
|
|
247
|
+
return (
|
|
248
|
+
commandName !== undefined &&
|
|
249
|
+
TELEGRAM_RESERVED_COMMAND_NAME_SET.has(commandName)
|
|
250
|
+
);
|
|
271
251
|
}
|
|
272
252
|
|
|
273
253
|
export type TelegramCommandAction =
|
|
@@ -600,8 +580,10 @@ export function buildTelegramAppMenuHtml(
|
|
|
600
580
|
statusHtml: string,
|
|
601
581
|
promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
|
|
602
582
|
): string {
|
|
603
|
-
const promptTemplateHtml =
|
|
604
|
-
|
|
583
|
+
const promptTemplateHtml =
|
|
584
|
+
buildTelegramPromptTemplateMenuHtml(promptTemplates);
|
|
585
|
+
if (!promptTemplateHtml)
|
|
586
|
+
return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${statusHtml}`;
|
|
605
587
|
return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${promptTemplateHtml}\n\n${statusHtml}`;
|
|
606
588
|
}
|
|
607
589
|
|
|
@@ -764,7 +746,7 @@ export async function handleTelegramCompactCommand(
|
|
|
764
746
|
deps.isCompactionInProgress()
|
|
765
747
|
) {
|
|
766
748
|
await deps.sendTextReply(
|
|
767
|
-
"Cannot compact while π or the Telegram queue is busy. Wait for queued turns to finish or send /
|
|
749
|
+
"Cannot compact while π or the Telegram queue is busy. Wait for queued turns to finish or send /abort first.",
|
|
768
750
|
);
|
|
769
751
|
return;
|
|
770
752
|
}
|
|
@@ -923,7 +905,6 @@ export function createTelegramCommandHandlerTargetRuntime<
|
|
|
923
905
|
setAllowedUserId: deps.setAllowedUserId,
|
|
924
906
|
registerBotCommands: createTelegramBotCommandRegistrar({
|
|
925
907
|
setMyCommands: deps.setMyCommands,
|
|
926
|
-
getPromptTemplateCommands: deps.getPromptTemplateCommands,
|
|
927
908
|
}),
|
|
928
909
|
persistConfig: deps.persistConfig,
|
|
929
910
|
sendTextReply: commandTargetRuntime.sendTextReply,
|
|
@@ -955,7 +936,11 @@ export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
|
|
|
955
936
|
const firstMessage = messages[0];
|
|
956
937
|
if (!firstMessage) return;
|
|
957
938
|
const command = parseTelegramCommand(deps.extractRawText(messages));
|
|
958
|
-
const handled = await deps.handleCommand(
|
|
939
|
+
const handled = await deps.handleCommand(
|
|
940
|
+
command?.name,
|
|
941
|
+
firstMessage,
|
|
942
|
+
ctx,
|
|
943
|
+
);
|
|
959
944
|
if (handled) return;
|
|
960
945
|
if (command?.name && deps.expandPromptTemplateCommand) {
|
|
961
946
|
const expanded = deps.expandPromptTemplateCommand(
|
|
@@ -964,7 +949,10 @@ export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
|
|
|
964
949
|
);
|
|
965
950
|
if (expanded !== undefined) {
|
|
966
951
|
await deps.enqueueTurn(
|
|
967
|
-
[
|
|
952
|
+
[
|
|
953
|
+
deps.replaceMessageText(firstMessage, expanded),
|
|
954
|
+
...messages.slice(1),
|
|
955
|
+
],
|
|
968
956
|
ctx,
|
|
969
957
|
);
|
|
970
958
|
return;
|
package/lib/menu-queue.ts
CHANGED
|
@@ -24,11 +24,13 @@ function getTelegramQueueItemPromptText<Context>(
|
|
|
24
24
|
item: Queue.TelegramQueueItem<Context>,
|
|
25
25
|
): string {
|
|
26
26
|
if (item.kind !== "prompt") return item.statusSummary;
|
|
27
|
-
return
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
return (
|
|
28
|
+
item.content
|
|
29
|
+
.filter((block) => block.type === "text")
|
|
30
|
+
.map((block) => block.text)
|
|
31
|
+
.join("\n")
|
|
32
|
+
.trim() || item.statusSummary
|
|
33
|
+
);
|
|
32
34
|
}
|
|
33
35
|
function toTelegramQueueMenuItems<Context>(
|
|
34
36
|
items: readonly Queue.TelegramQueueItem<Context>[],
|
|
@@ -461,7 +463,7 @@ function createQueueMenuCallbackHandler<
|
|
|
461
463
|
const messageId = query.message?.message_id;
|
|
462
464
|
if (!data || typeof chatId !== "number" || typeof messageId !== "number")
|
|
463
465
|
return false;
|
|
464
|
-
if (data === "status:queue") {
|
|
466
|
+
if (data === "menu:queue" || data === "status:queue") {
|
|
465
467
|
const state = deps.getStoredModelMenuState(messageId);
|
|
466
468
|
if (!state) {
|
|
467
469
|
await deps.answerCallbackQuery(
|
package/lib/menu-status.ts
CHANGED
|
@@ -51,7 +51,7 @@ function isTelegramStatusMenuCallbackAction(
|
|
|
51
51
|
data: string | undefined,
|
|
52
52
|
action: "model" | "thinking",
|
|
53
53
|
): boolean {
|
|
54
|
-
return data === `status:${action}`;
|
|
54
|
+
return data === `menu:${action}` || data === `status:${action}`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function applyTelegramMenuRenderPayload(
|
|
@@ -144,7 +144,7 @@ export function buildStatusReplyMarkup(
|
|
|
144
144
|
`${formatTelegramCommandEmojiPrefix("model")}Model`,
|
|
145
145
|
activeModel ? getCanonicalModelId(activeModel) : "unknown",
|
|
146
146
|
),
|
|
147
|
-
callback_data: "
|
|
147
|
+
callback_data: "menu:model",
|
|
148
148
|
},
|
|
149
149
|
]);
|
|
150
150
|
if (activeModel?.reasoning) {
|
|
@@ -154,14 +154,14 @@ export function buildStatusReplyMarkup(
|
|
|
154
154
|
`${formatTelegramCommandEmojiPrefix("thinking")}Thinking`,
|
|
155
155
|
currentThinkingLevel,
|
|
156
156
|
),
|
|
157
|
-
callback_data: "
|
|
157
|
+
callback_data: "menu:thinking",
|
|
158
158
|
},
|
|
159
159
|
]);
|
|
160
160
|
}
|
|
161
161
|
rows.push([
|
|
162
162
|
{
|
|
163
163
|
text: `🔢 Queue: ${queueItemCount}`,
|
|
164
|
-
callback_data: "
|
|
164
|
+
callback_data: "menu:queue",
|
|
165
165
|
},
|
|
166
166
|
]);
|
|
167
167
|
return { inline_keyboard: rows };
|
package/lib/menu.ts
CHANGED
|
@@ -276,11 +276,15 @@ export type TelegramMenuCallbackAction =
|
|
|
276
276
|
export function parseTelegramMenuCallbackAction(
|
|
277
277
|
data: string | undefined,
|
|
278
278
|
): TelegramMenuCallbackAction {
|
|
279
|
-
if (data === "
|
|
280
|
-
|
|
279
|
+
if (data === "menu:model" || data === "status:model") {
|
|
280
|
+
return { kind: "status", action: "model" };
|
|
281
|
+
}
|
|
282
|
+
if (data === "menu:thinking" || data === "status:thinking") {
|
|
281
283
|
return { kind: "status", action: "thinking" };
|
|
282
284
|
}
|
|
283
|
-
if (data === "
|
|
285
|
+
if (data === "menu:queue" || data === "status:queue") {
|
|
286
|
+
return { kind: "status", action: "queue" };
|
|
287
|
+
}
|
|
284
288
|
if (data?.startsWith("thinking:set:")) {
|
|
285
289
|
return {
|
|
286
290
|
kind: "thinking:set",
|
package/lib/outbound-handlers.ts
CHANGED
|
@@ -476,7 +476,9 @@ async function runVoiceReplyCommand(
|
|
|
476
476
|
{
|
|
477
477
|
cwd: options.cwd,
|
|
478
478
|
timeout: options.timeout,
|
|
479
|
-
...(typeof config === "object" && config.retry !== undefined
|
|
479
|
+
...(typeof config === "object" && config.retry !== undefined
|
|
480
|
+
? { retry: config.retry }
|
|
481
|
+
: {}),
|
|
480
482
|
...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
|
|
481
483
|
},
|
|
482
484
|
);
|
package/lib/prompts.ts
CHANGED
|
@@ -15,6 +15,7 @@ Inbound context:
|
|
|
15
15
|
- \`[telegram]\` marks Telegram-originated messages.
|
|
16
16
|
- \`[reply]\` is quoted context from the replied-to message, not a new instruction by itself. 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
17
|
- \`[attachments]\` gives a base directory plus relative local files; resolve and read them as needed. \`[outputs]\` contains attachment-handler stdout such as transcriptions or extracted text for those attachments.
|
|
18
|
+
- Unknown \`[callback] ...\` messages may be intended for another extension; if you see one, say the callback was not handled and the environment may be misconfigured.
|
|
18
19
|
|
|
19
20
|
Telegram-visible output:
|
|
20
21
|
- Telegram is often phone-width; prefer narrow table columns because wide monospace tables can become unreadable.
|
package/lib/queue.ts
CHANGED
|
@@ -817,11 +817,20 @@ export interface TelegramAgentEndHookRuntimeDeps<
|
|
|
817
817
|
) => void;
|
|
818
818
|
clearPreview: (chatId: number) => Promise<void>;
|
|
819
819
|
setPreviewPendingText: (text: string) => void;
|
|
820
|
-
finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<
|
|
821
|
-
|
|
820
|
+
finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<
|
|
821
|
+
TTurn,
|
|
822
|
+
TReplyMarkup
|
|
823
|
+
>["finalizeMarkdownPreview"];
|
|
824
|
+
sendMarkdownReply: TelegramAgentEndRuntimeDeps<
|
|
825
|
+
TTurn,
|
|
826
|
+
TReplyMarkup
|
|
827
|
+
>["sendMarkdownReply"];
|
|
822
828
|
sendTextReply: TelegramAgentEndRuntimeDeps<TTurn>["sendTextReply"];
|
|
823
829
|
sendQueuedAttachments: (turn: TTurn) => Promise<void>;
|
|
824
|
-
planOutboundReply?: TelegramAgentEndRuntimeDeps<
|
|
830
|
+
planOutboundReply?: TelegramAgentEndRuntimeDeps<
|
|
831
|
+
TTurn,
|
|
832
|
+
TReplyMarkup
|
|
833
|
+
>["planOutboundReply"];
|
|
825
834
|
sendOutboundReplyArtifacts?: TelegramAgentEndRuntimeDeps<TTurn>["sendOutboundReplyArtifacts"];
|
|
826
835
|
}
|
|
827
836
|
|
package/lib/replies.ts
CHANGED
|
@@ -391,7 +391,9 @@ export function dedupSendTextReply(
|
|
|
391
391
|
options?: { parseMode?: "HTML" },
|
|
392
392
|
) => Promise<number | undefined> {
|
|
393
393
|
return async (chatId, replyToMessageId, text, options) => {
|
|
394
|
-
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
|
|
394
|
+
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
|
|
395
|
+
? replyToMessageId
|
|
396
|
+
: undefined;
|
|
395
397
|
return inner(chatId, effectiveReplyTo, text, options);
|
|
396
398
|
};
|
|
397
399
|
}
|
|
@@ -412,7 +414,9 @@ export function dedupSendMarkdownReply<TReplyMarkup = unknown>(
|
|
|
412
414
|
options?: { replyMarkup?: TReplyMarkup },
|
|
413
415
|
) => Promise<number | undefined> {
|
|
414
416
|
return async (chatId, replyToMessageId, markdown, options) => {
|
|
415
|
-
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
|
|
417
|
+
const effectiveReplyTo = dedup.shouldReply(replyToMessageId)
|
|
418
|
+
? replyToMessageId
|
|
419
|
+
: undefined;
|
|
416
420
|
return inner(chatId, effectiveReplyTo, markdown, options);
|
|
417
421
|
};
|
|
418
422
|
}
|
package/lib/routing.ts
CHANGED
|
@@ -14,6 +14,7 @@ import * as Model from "./model.ts";
|
|
|
14
14
|
import * as Queue from "./queue.ts";
|
|
15
15
|
import * as PromptTemplates from "./prompt-templates.ts";
|
|
16
16
|
import type { TelegramBridgeRuntime } from "./runtime.ts";
|
|
17
|
+
import * as TextGroups from "./text-groups.ts";
|
|
17
18
|
import * as Turns from "./turns.ts";
|
|
18
19
|
import * as Updates from "./updates.ts";
|
|
19
20
|
|
|
@@ -39,6 +40,7 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
39
40
|
bridgeRuntime: TelegramBridgeRuntime;
|
|
40
41
|
activeTurnRuntime: Queue.TelegramActiveTurnStore;
|
|
41
42
|
mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
|
|
43
|
+
textGroupRuntime: TextGroups.TelegramTextGroupController<TMessage, TContext>;
|
|
42
44
|
telegramQueueStore: Queue.TelegramQueueStateStore<TContext>;
|
|
43
45
|
queueMutationRuntime: Queue.TelegramQueueMutationController<TContext>;
|
|
44
46
|
modelMenuRuntime: Menu.TelegramModelMenuRuntime<TModel>;
|
|
@@ -71,11 +73,14 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
71
73
|
text: string,
|
|
72
74
|
) => Promise<number | undefined>;
|
|
73
75
|
setMyCommands: Commands.TelegramBotCommandRegistrationDeps["setMyCommands"];
|
|
74
|
-
getCommands: () => Parameters<
|
|
76
|
+
getCommands: () => Parameters<
|
|
77
|
+
typeof PromptTemplates.getTelegramPromptTemplateCommands
|
|
78
|
+
>[0];
|
|
75
79
|
downloadFile: Media.DownloadTelegramMessageFilesDeps["downloadFile"];
|
|
76
80
|
getThinkingLevel: () => Model.ThinkingLevel;
|
|
77
81
|
setThinkingLevel: (level: Model.ThinkingLevel) => void;
|
|
78
82
|
setModel: (model: TModel) => Promise<boolean>;
|
|
83
|
+
sendUserMessage?: (message: string) => void;
|
|
79
84
|
isIdle: (ctx: TContext) => boolean;
|
|
80
85
|
hasPendingMessages: (ctx: TContext) => boolean;
|
|
81
86
|
compact: (
|
|
@@ -89,6 +94,21 @@ export interface TelegramInboundRouteRuntimeDeps<
|
|
|
89
94
|
) => void;
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
|
|
98
|
+
"menu:",
|
|
99
|
+
"model:",
|
|
100
|
+
"queue:",
|
|
101
|
+
"status:",
|
|
102
|
+
"tgbtn:",
|
|
103
|
+
"thinking:",
|
|
104
|
+
] as const;
|
|
105
|
+
|
|
106
|
+
function isTelegramOwnedCallbackData(data: string): boolean {
|
|
107
|
+
return TELEGRAM_OWNED_CALLBACK_PREFIXES.some((prefix) =>
|
|
108
|
+
data.startsWith(prefix),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
92
112
|
export function createTelegramInboundRouteRuntime<
|
|
93
113
|
TUpdate extends Updates.TelegramUpdateFlow & {
|
|
94
114
|
message?: TMessage;
|
|
@@ -167,6 +187,16 @@ export function createTelegramInboundRouteRuntime<
|
|
|
167
187
|
}
|
|
168
188
|
const handledByQueue = await deps.queueMenuCallbackHandler(query, ctx);
|
|
169
189
|
if (handledByQueue) return;
|
|
190
|
+
const callbackData = query.data;
|
|
191
|
+
if (
|
|
192
|
+
deps.sendUserMessage &&
|
|
193
|
+
callbackData &&
|
|
194
|
+
!isTelegramOwnedCallbackData(callbackData)
|
|
195
|
+
) {
|
|
196
|
+
deps.sendUserMessage(`[callback] ${callbackData}`);
|
|
197
|
+
await deps.answerCallbackQuery(query.id);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
170
200
|
await menuCallbackHandler(query, ctx);
|
|
171
201
|
};
|
|
172
202
|
const promptTurnBuilder = Turns.createTelegramPromptTurnRuntimeBuilder<
|
|
@@ -206,7 +236,9 @@ export function createTelegramInboundRouteRuntime<
|
|
|
206
236
|
deps.queueMutationRuntime.append(continueTurn, ctx);
|
|
207
237
|
deps.dispatchNextQueuedTelegramTurn(ctx);
|
|
208
238
|
};
|
|
209
|
-
const reservedCommandNames = new Set(
|
|
239
|
+
const reservedCommandNames = new Set(
|
|
240
|
+
Commands.TELEGRAM_RESERVED_COMMAND_NAMES,
|
|
241
|
+
);
|
|
210
242
|
const getPromptTemplateCommands = () =>
|
|
211
243
|
PromptTemplates.getTelegramPromptTemplateCommands(
|
|
212
244
|
deps.getCommands(),
|
|
@@ -291,6 +323,14 @@ export function createTelegramInboundRouteRuntime<
|
|
|
291
323
|
mediaGroups: deps.mediaGroupRuntime,
|
|
292
324
|
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
293
325
|
});
|
|
326
|
+
const textDispatch = TextGroups.createTelegramTextGroupDispatchRuntime<
|
|
327
|
+
TMessage,
|
|
328
|
+
TContext
|
|
329
|
+
>({
|
|
330
|
+
textGroups: deps.textGroupRuntime,
|
|
331
|
+
dispatchMessages: commandOrPrompt.dispatchMessages,
|
|
332
|
+
dispatchSingleMessage: mediaDispatch.handleMessage,
|
|
333
|
+
});
|
|
294
334
|
const editRuntime = Turns.createTelegramQueuedPromptEditRuntime<
|
|
295
335
|
TMessage,
|
|
296
336
|
TContext
|
|
@@ -313,7 +353,7 @@ export function createTelegramInboundRouteRuntime<
|
|
|
313
353
|
answerCallbackQuery: deps.answerCallbackQuery,
|
|
314
354
|
handleAuthorizedTelegramCallbackQuery: callbackHandler,
|
|
315
355
|
sendTextReply: deps.sendTextReply,
|
|
316
|
-
handleAuthorizedTelegramMessage:
|
|
356
|
+
handleAuthorizedTelegramMessage: textDispatch.handleMessage,
|
|
317
357
|
handleAuthorizedTelegramEditedMessage: editRuntime.updateFromEditedMessage,
|
|
318
358
|
});
|
|
319
359
|
}
|
package/lib/status.ts
CHANGED
|
@@ -409,13 +409,16 @@ export function buildTelegramStatusBarText(
|
|
|
409
409
|
return `${label} ${theme.fg("muted", "disconnected")}`;
|
|
410
410
|
if (!state.paired)
|
|
411
411
|
return `${label} ${theme.fg("warning", "awaiting pairing")}`;
|
|
412
|
-
const queued = state.queuedStatus
|
|
412
|
+
const queued = state.queuedStatus
|
|
413
|
+
? theme.fg("muted", state.queuedStatus)
|
|
414
|
+
: "";
|
|
413
415
|
if (state.compactionInProgress) {
|
|
414
416
|
return `${label} ${theme.fg("accent", "compacting")}${queued}`;
|
|
415
417
|
}
|
|
416
418
|
if (state.processing) {
|
|
417
419
|
const processingStatus = state.processingStatus ?? "processing";
|
|
418
|
-
const processingToken =
|
|
420
|
+
const processingToken =
|
|
421
|
+
processingStatus === "active" ? "warning" : "accent";
|
|
419
422
|
return `${label} ${theme.fg(processingToken, processingStatus)}${queued}`;
|
|
420
423
|
}
|
|
421
424
|
return `${label} ${theme.fg("success", "connected")}`;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram text-group coalescing helpers
|
|
3
|
+
* Zones: telegram inbound, queue admission, split-message recovery
|
|
4
|
+
* Owns conservative delayed grouping for Telegram text messages that look like automatic long-message splits
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const TELEGRAM_TEXT_GROUP_DEBOUNCE_MS = 700;
|
|
8
|
+
const TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH = 3600;
|
|
9
|
+
|
|
10
|
+
export interface TelegramTextGroupMessage {
|
|
11
|
+
message_id: number;
|
|
12
|
+
media_group_id?: string;
|
|
13
|
+
chat: { id: number };
|
|
14
|
+
from?: { id: number; is_bot?: boolean };
|
|
15
|
+
text?: string;
|
|
16
|
+
caption?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TelegramTextGroupState<TMessage, TContext = unknown> {
|
|
20
|
+
messages: TMessage[];
|
|
21
|
+
context?: TContext;
|
|
22
|
+
flushTimer?: ReturnType<typeof setTimeout>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TelegramTextGroupController<TMessage, TContext = unknown> {
|
|
26
|
+
queueMessage: (options: {
|
|
27
|
+
message: TMessage;
|
|
28
|
+
context: TContext;
|
|
29
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
|
|
30
|
+
}) => boolean;
|
|
31
|
+
clear: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TelegramTextGroupControllerOptions {
|
|
35
|
+
debounceMs?: number;
|
|
36
|
+
minSplitLength?: number;
|
|
37
|
+
setTimer?: (
|
|
38
|
+
callback: () => void,
|
|
39
|
+
ms: number,
|
|
40
|
+
) => ReturnType<typeof setTimeout>;
|
|
41
|
+
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TelegramTextGroupDispatchRuntime<
|
|
45
|
+
TMessage extends TelegramTextGroupMessage,
|
|
46
|
+
TContext,
|
|
47
|
+
> {
|
|
48
|
+
handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface TelegramGroupedInputClearerDeps {
|
|
52
|
+
clearMediaGroups: () => void;
|
|
53
|
+
clearTextGroups: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractTelegramTextGroupText(
|
|
57
|
+
message: TelegramTextGroupMessage,
|
|
58
|
+
): string {
|
|
59
|
+
return typeof message.text === "string" ? message.text : "";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isTelegramTextGroupCommand(text: string): boolean {
|
|
63
|
+
return text.trimStart().startsWith("/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getTelegramTextGroupKey(
|
|
67
|
+
message: TelegramTextGroupMessage,
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (message.media_group_id) return undefined;
|
|
70
|
+
if (!message.from || message.from.is_bot) return undefined;
|
|
71
|
+
if (typeof message.text !== "string") return undefined;
|
|
72
|
+
return `${message.chat.id}:${message.from.id}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canStartTelegramTextGroup(
|
|
76
|
+
message: TelegramTextGroupMessage,
|
|
77
|
+
minSplitLength: number,
|
|
78
|
+
): boolean {
|
|
79
|
+
const text = extractTelegramTextGroupText(message);
|
|
80
|
+
return text.length >= minSplitLength && !isTelegramTextGroupCommand(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function canAppendTelegramTextGroupMessage<
|
|
84
|
+
TMessage extends TelegramTextGroupMessage,
|
|
85
|
+
>(
|
|
86
|
+
state: TelegramTextGroupState<TMessage, unknown>,
|
|
87
|
+
message: TMessage,
|
|
88
|
+
): boolean {
|
|
89
|
+
const text = extractTelegramTextGroupText(message);
|
|
90
|
+
const previous = state.messages.at(-1);
|
|
91
|
+
return (
|
|
92
|
+
!!previous &&
|
|
93
|
+
message.message_id > previous.message_id &&
|
|
94
|
+
message.message_id <= previous.message_id + 2 &&
|
|
95
|
+
text.length > 0 &&
|
|
96
|
+
!isTelegramTextGroupCommand(text)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function queueTelegramTextGroupMessage<
|
|
101
|
+
TMessage extends TelegramTextGroupMessage,
|
|
102
|
+
TContext = unknown,
|
|
103
|
+
>(options: {
|
|
104
|
+
message: TMessage;
|
|
105
|
+
context: TContext;
|
|
106
|
+
groups: Map<string, TelegramTextGroupState<TMessage, TContext>>;
|
|
107
|
+
debounceMs: number;
|
|
108
|
+
minSplitLength: number;
|
|
109
|
+
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
110
|
+
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
111
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => void;
|
|
112
|
+
}): boolean {
|
|
113
|
+
const key = getTelegramTextGroupKey(options.message);
|
|
114
|
+
if (!key) return false;
|
|
115
|
+
const existing = options.groups.get(key);
|
|
116
|
+
if (
|
|
117
|
+
!existing &&
|
|
118
|
+
!canStartTelegramTextGroup(options.message, options.minSplitLength)
|
|
119
|
+
)
|
|
120
|
+
return false;
|
|
121
|
+
if (existing && !canAppendTelegramTextGroupMessage(existing, options.message))
|
|
122
|
+
return false;
|
|
123
|
+
const state = existing ?? { messages: [] };
|
|
124
|
+
state.messages.push(options.message);
|
|
125
|
+
state.context = options.context;
|
|
126
|
+
if (state.flushTimer) options.clearTimer(state.flushTimer);
|
|
127
|
+
state.flushTimer = options.setTimer(() => {
|
|
128
|
+
const queued = options.groups.get(key);
|
|
129
|
+
options.groups.delete(key);
|
|
130
|
+
if (!queued || queued.context === undefined) return;
|
|
131
|
+
options.dispatchMessages(queued.messages, queued.context);
|
|
132
|
+
}, options.debounceMs);
|
|
133
|
+
options.groups.set(key, state);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createTelegramTextGroupController<
|
|
138
|
+
TMessage extends TelegramTextGroupMessage,
|
|
139
|
+
TContext = unknown,
|
|
140
|
+
>(
|
|
141
|
+
options: TelegramTextGroupControllerOptions = {},
|
|
142
|
+
): TelegramTextGroupController<TMessage, TContext> {
|
|
143
|
+
const groups = new Map<string, TelegramTextGroupState<TMessage, TContext>>();
|
|
144
|
+
const debounceMs = options.debounceMs ?? TELEGRAM_TEXT_GROUP_DEBOUNCE_MS;
|
|
145
|
+
const minSplitLength =
|
|
146
|
+
options.minSplitLength ?? TELEGRAM_TEXT_GROUP_MIN_SPLIT_LENGTH;
|
|
147
|
+
const setTimer =
|
|
148
|
+
options.setTimer ??
|
|
149
|
+
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
150
|
+
setTimeout(callback, ms));
|
|
151
|
+
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
152
|
+
return {
|
|
153
|
+
queueMessage: ({ message, context, dispatchMessages }) =>
|
|
154
|
+
queueTelegramTextGroupMessage({
|
|
155
|
+
message,
|
|
156
|
+
context,
|
|
157
|
+
groups,
|
|
158
|
+
debounceMs,
|
|
159
|
+
minSplitLength,
|
|
160
|
+
setTimer,
|
|
161
|
+
clearTimer,
|
|
162
|
+
dispatchMessages,
|
|
163
|
+
}),
|
|
164
|
+
clear: () => {
|
|
165
|
+
for (const state of groups.values()) {
|
|
166
|
+
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
167
|
+
}
|
|
168
|
+
groups.clear();
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createTelegramTextGroupDispatchRuntime<
|
|
174
|
+
TMessage extends TelegramTextGroupMessage,
|
|
175
|
+
TContext,
|
|
176
|
+
>(deps: {
|
|
177
|
+
textGroups: TelegramTextGroupController<TMessage, TContext>;
|
|
178
|
+
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
|
|
179
|
+
dispatchSingleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
180
|
+
}): TelegramTextGroupDispatchRuntime<TMessage, TContext> {
|
|
181
|
+
return {
|
|
182
|
+
handleMessage: async (message, ctx) => {
|
|
183
|
+
const queuedTextGroup = deps.textGroups.queueMessage({
|
|
184
|
+
message,
|
|
185
|
+
context: ctx,
|
|
186
|
+
dispatchMessages: (messages, queuedCtx) => {
|
|
187
|
+
void deps.dispatchMessages(messages, queuedCtx);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
if (queuedTextGroup) return;
|
|
191
|
+
await deps.dispatchSingleMessage(message, ctx);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createTelegramGroupedInputClearer(
|
|
197
|
+
deps: TelegramGroupedInputClearerDeps,
|
|
198
|
+
): () => void {
|
|
199
|
+
return () => {
|
|
200
|
+
deps.clearMediaGroups();
|
|
201
|
+
deps.clearTextGroups();
|
|
202
|
+
};
|
|
203
|
+
}
|
package/lib/updates.ts
CHANGED
|
@@ -26,8 +26,18 @@ export type TelegramReactionType =
|
|
|
26
26
|
| TelegramReactionTypeEmoji
|
|
27
27
|
| TelegramReactionTypeNonEmoji;
|
|
28
28
|
|
|
29
|
-
export const TELEGRAM_PRIORITY_REACTION_EMOJIS = [
|
|
30
|
-
|
|
29
|
+
export const TELEGRAM_PRIORITY_REACTION_EMOJIS = [
|
|
30
|
+
"👍",
|
|
31
|
+
"⚡",
|
|
32
|
+
"❤",
|
|
33
|
+
"🕊",
|
|
34
|
+
] as const;
|
|
35
|
+
export const TELEGRAM_REMOVAL_REACTION_EMOJIS = [
|
|
36
|
+
"👎",
|
|
37
|
+
"👻",
|
|
38
|
+
"💔",
|
|
39
|
+
"💩",
|
|
40
|
+
] as const;
|
|
31
41
|
|
|
32
42
|
export interface TelegramUpdateDeletion {
|
|
33
43
|
deleted_business_messages?: { message_ids?: unknown };
|
|
@@ -66,7 +76,9 @@ function hasAddedTelegramReactionEmoji(
|
|
|
66
76
|
newEmojis: Set<string>,
|
|
67
77
|
candidates: readonly string[],
|
|
68
78
|
): boolean {
|
|
69
|
-
return candidates.some(
|
|
79
|
+
return candidates.some(
|
|
80
|
+
(emoji) => !oldEmojis.has(emoji) && newEmojis.has(emoji),
|
|
81
|
+
);
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
export function extractDeletedTelegramMessageIds(
|
|
@@ -626,7 +638,14 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
|
|
|
626
638
|
deps.ctx,
|
|
627
639
|
);
|
|
628
640
|
}
|
|
629
|
-
if (
|
|
641
|
+
if (
|
|
642
|
+
!hasAddedTelegramReactionEmoji(
|
|
643
|
+
oldEmojis,
|
|
644
|
+
newEmojis,
|
|
645
|
+
TELEGRAM_PRIORITY_REACTION_EMOJIS,
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
return;
|
|
630
649
|
deps.prioritizeQueuedTelegramTurnByMessageId(
|
|
631
650
|
reactionUpdate.message_id,
|
|
632
651
|
deps.ctx,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-telegram",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Better Telegram DM bridge extension for
|
|
5
|
+
"description": "Better Telegram DM bridge extension for π",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"pi-package",
|