@llblab/pi-kit 0.36.3 → 0.37.1

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  4. package/node_modules/@llblab/pi-telegram/BACKLOG.md +9 -11
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +19 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +2 -1
  8. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +5 -6
  9. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +36 -35
  10. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +2 -3
  11. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +20 -18
  12. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +6 -6
  13. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +23 -65
  14. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +3 -3
  15. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.d.ts +1 -1
  16. package/node_modules/@llblab/pi-telegram/dist/lib/prompts.js +1 -1
  17. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +1 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +1 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +4 -2
  20. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +29 -20
  21. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  22. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/SKILL.md +5 -5
  23. package/node_modules/@llblab/pi-telegram/docs/architecture.md +6 -6
  24. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +1 -1
  25. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +1 -1
  26. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +1 -1
  27. package/node_modules/@llblab/pi-telegram/docs/outbound.md +8 -8
  28. package/node_modules/@llblab/pi-telegram/docs/public-api.md +5 -3
  29. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +4 -0
  30. package/node_modules/@llblab/pi-telegram/lib/commands.ts +53 -43
  31. package/node_modules/@llblab/pi-telegram/lib/extension.ts +1 -3
  32. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +17 -15
  33. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +28 -68
  34. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +3 -3
  35. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +1 -1
  36. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +2 -1
  37. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +31 -18
  38. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  39. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +5 -5
  40. package/package.json +2 -2
@@ -2,7 +2,7 @@
2
2
 
3
3
  `pi-telegram` maps hidden assistant-authored HTML comments to Telegram-native outbound actions.
4
4
 
5
- Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden top-level blocks, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. Action activation remains restricted to recognized top-level column-zero comments, but Telegram preview and final delivery remove every assistant-authored `<!-- … -->` block regardless of Markdown position or owning extension. Unclosed comment tails are withheld, comment-only text plans send no message, and the Pi terminal transcript remains unchanged. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response. Its `thread` argument accepts a case-insensitive live thread name or numeric id: the bridge preflights one live owner, sends visibly, then admits the text as a source-attributed turn in that instance. Unknown, ambiguous, same-target, offline, and cross-chat destinations fail before sending. Existing `chat_id` plus `thread_id` targeting stays compatible; registered followers use authenticated, generation-fenced bus routing. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded top-level `telegram_button` comments become buttons attached to that text message.
5
+ Normal Telegram-turn replies are intentionally prompt-driven: the agent writes Markdown plus small hidden HTML comments, and the bridge performs transport after `agent_end`. `telegram_voice` and `telegram_button` are not Pi tools. Complete action comments activate by their delimiters and accepted payload regardless of surrounding whitespace or Markdown, including code fences; Telegram preview and final delivery remove every assistant-authored `<!-- … -->` block regardless of Markdown position or owning extension. Unclosed comment tails are withheld, comment-only text plans send no message, and the Pi terminal transcript remains unchanged. For local/TUI-initiated work where the user explicitly asks to send something to Telegram, the bridge also exposes direct tools: `telegram_message` for Markdown text and `telegram_attach` for file delivery when no Telegram turn is active. In classic mode, direct local/TUI delivery requires this Pi instance to own `/telegram-connect`; in Threaded Mode, a registered follower may route direct-tool sends through the leader-owned bus transport. During an active Telegram turn, `telegram_message` rejects an implicit or same-turn target so ordinary final delivery remains the sole current-target response. Its `thread` argument accepts a case-insensitive live thread name or numeric id: the bridge preflights one live owner, sends visibly, then admits the text as a source-attributed turn in that instance. Unknown, ambiguous, same-target, offline, and cross-chat destinations fail before sending. Existing `chat_id` plus `thread_id` targeting stays compatible; registered followers use authenticated, generation-fenced bus routing. Outbound behavior combines assistant prompt markup, text command-template handlers, registered voice synthesis providers, generated artifacts, direct Telegram tools, and reply delivery. Direct `telegram_message` text is planned through the same reply markup path, so embedded `telegram_button` comments become buttons attached to that text message.
6
6
 
7
7
  Text handlers use the portable [Command Template Standard](./command-templates.md). Programmatic outbound handlers use `registerTelegramOutboundHandler(kind, handler)`. Voice replies can use configured command-template handlers or the provider API described in [Voice Integration](./voice.md).
8
8
 
@@ -163,11 +163,11 @@ Full text answer stays here.
163
163
  <!-- telegram_voice {"text":"First line.\nSecond line.","lang":"ru"} -->
164
164
  ```
165
165
 
166
- The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` action to a provider call, generates one file per action, and sends each file as an independent Telegram-native voice message. Prefer `{text}`, `{text|lang}`, or `{text|lang|rate}`. Use JSON when multiline content, named fields, or escaping earns the extra syntax; equivalent JSON `text` or `value` supplies the spoken payload, with explicit `text` taking precedence. Each comment creates one voice artifact, so voice cells do not accept button-style matrix composition. The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it does not activate a voice action and is still removed from the Telegram surface.
166
+ The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` action to a provider call, generates one file per action, and sends each file as an independent Telegram-native voice message. Prefer `{text}`, `{text|lang}`, or `{text|lang|rate}`. Use JSON when multiline content, named fields, or escaping earns the extra syntax; equivalent JSON `text` or `value` supplies the spoken payload, with explicit `text` taking precedence. Each comment creates one voice artifact, so voice cells do not accept button-style matrix composition. Complete voice comments activate anywhere, including inline, indented, quoted and fenced text; surrounding Markdown is not an action boundary. Escape the opening delimiter as `&lt;!--` for literal examples.
167
167
 
168
168
  ## Buttons Markup
169
169
 
170
- Assistant replies can include one or many button actions through a top-level `telegram_button` comment:
170
+ Assistant replies can include one or many button actions through a `telegram_button` HTML comment:
171
171
 
172
172
  ```md
173
173
  I can continue.
@@ -181,15 +181,15 @@ Rules:
181
181
 
182
182
  - The payload may be a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row. Commas are optional between completed elements, and one trailing comma before a closing delimiter is tolerated; JSON object validation likewise tolerates trailing commas but does not invent missing values, property names, or internal separators. CML uses `{value}`, `{label|prompt}`, `{|prompt}`, or the corresponding three-atom form with `selected_style`; the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling, permitting an empty style atom in this form: `{|Next||1}`. An omitted fourth position stays enabled. JSON uses boolean `disabled`; legacy attributes accept `disabled="true"` or `disabled="false"`. Invalid disabled values reject the candidate matrix atomically. Disabled cells require no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank, equivalent to JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`. They retain their row and label, serialize as `{ text, disabled: {} }` (non-breaking-space text for a blank cell), and register no callback, prompt, or bound app action. It trims every atom, preserves non-structural printable text, and decodes only `\|`, `\}`, and `\\`.
183
183
  - Use `label` plus `prompt`, or the compact `value` key when both strings are identical. If only `label`, only `prompt`, one-field `{value}`, or prompt-only `{|prompt}` is present, that string supplies both visible label and queued prompt. An explicit counterpart takes precedence over `value`. Use JSON with `\n` escapes for multiline prompts.
184
- - The opening marker must start at column zero on a top-level line outside fenced code, quotes, lists, and indented examples; otherwise it does not activate a button action and is still removed from the Telegram surface.
184
+ - Complete HTML comments activate by their delimiters and accepted payload, regardless of indentation, surrounding text, lists, quotes or code fences. Multiple comments on one line are independent. Escape the opening delimiter as `&lt;!--` for literal examples; backticks and backslashes do not disable action comments.
185
185
  - Prefer one matrix comment for multiple buttons. Each top-level JSON object or CML cell becomes one full-width inline-keyboard row in source order; a nested row groups one or more buttons horizontally. The parser imposes no artificial per-row width cap; empty rows, malformed cells, unknown/trailing CML escapes, a fourth unescaped CML separator, empty enabled-cell prompts or three-atom styles, invalid disabled flags, empty one-atom cells, unknown selected styles, and deeper nesting are rejected atomically. Multi-atom cells may omit the label; four-atom cells may additionally omit the selected style, and disabled cells may omit the prompt. Generated surfaces default to five columns and expand to six through eight only for short position-bearing labels. Repeated singular comments remain valid.
186
186
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
187
- - Standalone column-zero fences opened by exactly three backticks and `telegram_button` use the same singleton/matrix JSON/CML grammar. Button wrappers accept adjacent complete JSON/CML cells and matrices with optional commas between groups. Their normalized rows concatenate in source order: `[{A|a}] [{B|b}]` equals `[{A|a}{B|b}]`; a singleton cell is one vertical row and horizontal peers still require a row nested inside a matrix. Any invalid group in a fence rejects the entire block before callback registration. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
187
+ - Standalone fences opened by at least three backticks or tildes and the exact name `telegram_button` use the same singleton/matrix JSON/CML grammar. Delimiter lines accept horizontal indentation, optional spaces/tabs before the name, and trailing whitespace. A closing line must use the same marker with at least the opening length and only surrounding horizontal whitespace; inline, quoted/list-prefixed delimiters and name suffixes do not activate a block. Button wrappers accept adjacent complete JSON/CML cells and matrices with optional commas between groups. Their normalized rows concatenate in source order: `[{A|a}] [{B|b}]` equals `[{A|a}{B|b}]`; a singleton cell is one vertical row and horizontal peers still require a row nested inside a matrix. Any invalid group in a fence rejects the entire block before callback registration. Complete valid blocks become native `<tg-button-row>` elements in `InputRichMessage.markdown`, at their original positions. Each row supports at most eight buttons and must fit a Rich Message chunk; validation precedes callback registration for the entire block. Labels are escaped, prompts never enter the native markup, and disabled cells have no callback. Missing closures, trailing payload garbage, and invalid blocks produce no controls. Other language names and enclosing code examples stay literal. Draft previews withhold complete and unfinished action fences without registering actions. HTML compatibility projects fenced rows into the bottom keyboard before comment-authored footer rows; source ordering within each group is retained.
188
188
  - In-body controls share prompt/app dispatch, TTL, generation/revision checks, and sent-message target ownership with footer controls. Their acknowledgement is the selection feedback; `selected_style` does not recolor the Rich body. No callback-triggered body rewrite is introduced, preventing a stale click from overwriting newer app output. The operator confirmed the post-reload current-client smoke: singleton CML and mixed JSON/CML in-body rows, their ordinary prompt callbacks, a labeled disabled control, and the separate footer callback work as intended. Client type was not recorded. The operator also confirmed that HTML mode projects the in-body groups into the bottom keyboard as intended. Second-client coverage, follower routing, blank cells, and app-method dispatch/revision rejection remain live-gated. Bot API evidence: `InputRichBlockButtons` / Rich Markdown `tg-button-row` and `RichMessageButton` use ordinary `callback_data` and support disabled controls.
189
189
  - After Telegram accepts a generated footer button callback as a queued prompt, the bridge changes that exact button to its configured selection style without changing agent-authored text or emoji. Set `selected_style` to `primary` (blue), `success` (green), or `danger` (red); omitted or invalid values fall back to `primary`. The style never suppresses queue admission. Other choices stay visually unchanged and remain available; the callback acknowledgement remains the fallback on clients that do not render button styles.
190
190
  - When generated button markup is the entire assistant reply, the bridge supplies the standard `☑️ **Choose an option:**` heading as visible message text so Telegram has a message to which it can attach the inline keyboard.
191
191
 
192
- Do not emit inline comments after visible text, standalone button actions, or tool calls for ordinary Telegram-turn buttons. The agent writes Markdown plus hidden comments; the bridge strips comments and attaches Telegram `reply_markup` after `agent_end`. For local/TUI-originated direct sends, put the same Markdown and `telegram_button` comments in `telegram_message(text)`.
192
+ Prefer separate comment lines for readability; inline comments also activate. Do not use tool calls for ordinary Telegram-turn buttons. The agent writes Markdown plus hidden comments; the bridge strips comments and attaches Telegram `reply_markup` after `agent_end`. For local/TUI-originated direct sends, put the same Markdown and `telegram_button` comments in `telegram_message(text)`.
193
193
 
194
194
  Buttons are built in and do not need a command template because they are pure Telegram reply markup plus callback routing.
195
195
 
@@ -203,8 +203,8 @@ The extension injects prompt guidance by context:
203
203
  - For Telegram-originated turns, the compact note routes the agent to `telegram-bridge` for voice/button/direct-delivery/Threaded Mode/formatting/debug guidance and to `show-me` when the user needs a visual explanation of work or behavior.
204
204
  - For ordinary Telegram-originated answers, write the full technical answer as phone-width Markdown. `show-me` may instead deliver a focused self-contained HTML artifact when explicitly requested or when spatial density earns it, while keeping the immediate reply useful.
205
205
  - Add `telegram_voice` with positional CML by default or JSON when multiline content, named fields, or escaping requires it. A companion summary is optional, no specific summary format is required.
206
- - Add `telegram_button` with a JSON object, JSON matrix, or positional CML. Use a hidden comment for footer placement or a standalone triple-backtick `telegram_button` block for placement between paragraphs. Prefer one matrix for multiple controls. Use `label` plus `prompt`, or `value` when they are identical; `selected_style` and boolean `disabled` are optional. Keep at least one useful enabled action on an interactive surface. A button-only reply may omit parent text because the bridge supplies `☑️ **Choose an option:**` automatically.
207
- - 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.
206
+ - Add `telegram_button` with a JSON object, JSON matrix, or positional CML. Use a hidden comment for footer placement or a standalone `telegram_button` fenced block for placement between paragraphs. Prefer one matrix for multiple controls. Use `label` plus `prompt`, or `value` when they are identical; `selected_style` and boolean `disabled` are optional. Keep at least one useful enabled action on an interactive surface. A button-only reply may omit parent text because the bridge supplies `☑️ **Choose an option:**` automatically.
207
+ - 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 `telegram_button` comments in its Markdown text because those buttons are attached to that text message.
208
208
  - Prefer meaningful visible parent text when it adds context; for a button-only answer, rely on the bridge's automatic `☑️ **Choose an option:**` fallback rather than manufacturing duplicate text.
209
209
 
210
210
  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.
@@ -44,6 +44,8 @@ Stable commands inside Pi:
44
44
  - `/telegram-disconnect` — after destructive confirmation, stop polling and release ownership without deleting or silencing accepted local queue state. A successful command queues the corresponding hidden, non-triggering disconnected context note; cancelled or failed disconnects do not publish a false state transition. In Threaded Mode it deletes this instance's current Telegram thread; a follower waits for its active leader to confirm generation-fenced cleanup before stopping. Graceful Pi `quit` performs the same teardown without prompting, while session replacement suspends transport without disconnect teardown. Local `/resume` carries connection intent rather than the source Thread; other supported replacements retain their existing continuity rules.
45
45
  - `/telegram-status` — show connection, polling, execution, queue, and recent event diagnostics; debug output separates poller and worker progress, durable automatic-retry state, exact foreign queued-owner identity, and negotiated protocol/build/capabilities.
46
46
 
47
+ The `/telegram-connect --internal new <token>` form is a runtime-only dispatch, not a profile connection or a manual new-session shortcut. It requires the exact prepared one-use action and bypasses profile activation, connection intents and startup/recovery. Missing, forged, replayed, unsupported or malformed internal calls fail closed; invalid calls do not consume a valid pending action. The flag is reserved, but `internal` without the leading dashes remains a valid profile name. The standalone `/telegram-internal` command is no longer registered.
48
+
47
49
  ### Telegram commands
48
50
 
49
51
  - `/name Name` — set the durable manual display name of the current Thread. The routed leader or follower uses the authenticated target-fenced mutation and edits the visible title over the active automatic projection. Bare `/name` immediately opens expiring exact-target input; the next valid name is consumed before agent dispatch. Cancel is always available; **Reset to automatic** appears only when a manual name exists. Entering a bare uppercase slot letter such as `A` is also treated as an explicit reset to the current automatic display projection rather than as a manual name.
@@ -51,7 +53,7 @@ Stable commands inside Pi:
51
53
  Stable commands inside the paired Telegram DM:
52
54
 
53
55
  - `/start` — pair when needed and open the main application menu.
54
- - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-internal` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
56
+ - `/new` — after idle and empty-queue checks, request a new Pi session in the current classic chat or Thread. The bridge acknowledges the callback, deletes its confirmation, completes and removes the exact durable update, then dispatches one runtime-armed typed action through the internal `/telegram-connect --internal new <token>` Pi gateway using `pi.sendUserMessage(..., { expandPromptTemplates: true })`. Manual invocation reports that the gateway cannot be run manually; the armed handler receives a real `ExtensionCommandContext` and calls `ctx.newSession()`; one discriminated durable intent preserves either exact classic Profile/CWD/session/chat continuity or the Thread binding with slot/name re-key. The successor CAS-claims that intent before sending one terminal result. In a follower Thread the leader publishes and claims the intent on the follower's behalf over capability-gated, exact-generation local bus requests; followers never persist leader-owned state. Busy, identity-mismatch, stale-registration, incompatible-leader, and unavailable-host paths fail closed.
55
57
  - `/compact` — open confirmation and compact when idle.
56
58
  - `/next` — abort active work first when needed, attempt the interrupted prompt's abort notice, then reply `Dispatching next queued turn.` to the exact queued prompt selected for the next model turn. That dispatch notice owns the turn's one reply header; later answer messages do not reply to the same prompt again. Abort-notice failure is diagnostic and cannot block dispatch. A later `/abort` or `/stop` cancels both pending transition notices before taking ownership. The `/next` command itself is never the lifecycle-notice reply target, and aborted pending assistant text is suppressed.
57
59
  - `/continue` — enqueue a priority `continue` prompt.
@@ -66,7 +68,7 @@ An unbound Thread chooser offers **⛔️ Cancel routing** to the current paired
66
68
 
67
69
  ### Tools and assistant-authored actions
68
70
 
69
- Every assistant-authored HTML comment is transport-private on Telegram: previews and final replies remove `<!-- … -->` blocks regardless of Markdown nesting or which extension owns the comment, while only recognized top-level column-zero comments can activate voice or buttons. Unclosed comment tails are withheld and a comment-only result sends no text message; Pi's terminal transcript remains unchanged.
71
+ Every assistant-authored HTML comment is transport-private on Telegram: previews and final replies remove `<!-- … -->` blocks regardless of Markdown nesting or which extension owns the comment, and complete recognized comments activate voice or buttons independently of surrounding whitespace or Markdown, including code fences. Escape the opening delimiter as `&lt;!--` for literal examples. Unclosed comment tails are withheld and a comment-only result sends no text message; Pi's terminal transcript remains unchanged.
70
72
 
71
73
  - `telegram_bind({ app, script, argument? } | { app, method, argument? })` installs and initializes one canonical managed Generative App module under `<agent-dir>/genapps/<app>/<app>.mjs`, or invokes one named method on an installed app. Installation rejects silent replacement and noncanonical/symlink sources. Methods receive immutable JSON state, one optional JSON argument, cancellation, revision, and a bounded non-shell process port; successful state changes commit to `state.json` plus `states.jsonl`, while output-only methods leave history unchanged. After one-shot `tgbtn` resolution, a complete `app::method` or `app::method(<strict JSON>)` prompt invokes the installed app before Pi queue admission and sends its planned Markdown/buttons directly; malformed or failed bound actions never fall back to a model prompt. Direct app-output buttons retain hidden source revisions and stale actions fail before method execution; sibling processes serialize transitions and recover dead lock owners. Bound actions send a fresh message by default and retain the clicked button's selected state on its prior surface. A result may opt into `viewMode: "edit"` to replace the callback message and keyboard in place, with one fresh-send fallback only for that explicit action. Agent-mediated initial-surface revisions, process-birth lock proof, automatic refresh, and voice output are not implemented.
72
74
  - `telegram_attach(paths, chat_id?, thread_id?, caption?)` is the stable artifact delivery tool for generated files. During Telegram turns it queues files before any separate final text; with `assistant.rendering: "rich"`, exactly one PNG/JPEG, MP4, or MP3 artifact plus non-empty final Markdown can become one reply-anchored Rich Message with media first. HTML mode, multiple/unsupported files, Guest Mode, and voice outputs retain their established paths. Outside Telegram turns the tool sends files directly to the paired/default chat, the registered follower's assigned thread, or an explicit `chat_id` plus optional `thread_id` when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus.
@@ -75,7 +77,7 @@ Every assistant-authored HTML comment is transport-private on Telegram: previews
75
77
  - `telegram_message(text, chat_id?, media?, channel?, thread_id?, thread?)` sends a direct Telegram Markdown message when this Pi instance owns `/telegram-connect` or is registered with the multi-instance bus. A public `@username`, or an exact negative numeric channel ID with `channel: true`, is passed as `chat_id` without a local registry; channel delivery requires the direct leader, and Telegram enforces whether the bot has channel posting permission. Channel delivery accepts `media` as one local `.jpg`/`.jpeg`/`.png`/`.webp` photo or `.mp4` video (photo ≤ 10 MiB, video ≤ 50 MiB), uploaded through multipart `sendPhoto`/`sendVideo` with `text` as its HTML caption (≤ 1024 visible characters); unsupported media types and albums are rejected before issuance, and the durable channel-post journal binds media identity and caption so duplicate or lost-acknowledgement retries never re-upload. `thread` accepts a live numeric Thread id or its current acknowledged display title; name matching is case-insensitive and fails closed when absent or ambiguous, while delivery captures the numeric target. During an active Telegram turn, omitted targeting and an explicit target equal to that turn are rejected so the ordinary final-reply path remains the sole current-target response; an explicit different chat/thread target remains allowed. Outside active turns, paired/default local/TUI delivery remains unchanged. Top-level `telegram_button` comments inside `text` are parsed with the same planner used for normal replies and attached to that message; buttons are never standalone Telegram messages.
76
78
  - The bundled `telegram-bridge` Skill owns action syntax, target routing, Threaded Mode, formatting, Generative App operation, and profile-specific debugging guidance. The bundled `show-me` Skill owns portable evidence-honest explanations and adapts them to phone-width Markdown or self-contained HTML artifacts when Telegram is the active surface. The regular prompt routes applicable turns to these and the other bundled Skills. Compiled npm/git installations expose them through the package `pi.skills` manifest so Pi resource filters remain authoritative; a raw TypeScript checkout under Pi's `extensions` directory contributes its source Skill root at runtime instead. `telegram_attach`, `telegram_bind`, and `telegram_message` remain registered but are model-active only while this instance owns direct transport or holds a live follower registration; disconnect/loss suppresses their schemas and prompt metadata, and recovery restores only the operator's previously active pi-telegram subset.
77
79
  - `telegram_voice` hidden comments request Telegram-native voice delivery through `{text}`, `{text|lang}`, `{text|lang|rate}`, or a JSON object. JSON is the fallback for multiline content, named fields, or escaping; equivalent `text` or `value` supplies the spoken payload, with explicit `text` taking precedence.
78
- - `telegram_button` hidden comments create footer buttons; standalone column-zero triple-backtick `telegram_button` blocks create button rows between paragraphs in Native Rich Markdown. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; both wrappers also accept adjacent complete JSON/CML cells and matrices with optional separators, concatenating their normalized rows in source order. Cells remain singleton rows and nested horizontal rows retain their grouping. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. Use top-level column-zero action wrappers, outside quotes, lists, or enclosing code examples. Ordinary code fences and larger outer fences preserve literal examples; bare JSON/CML in prose never activates.
80
+ - `telegram_button` hidden comments create footer buttons; standalone `telegram_button` fenced blocks create button rows between paragraphs in Native Rich Markdown. Delimiter lines tolerate horizontal indentation and whitespace before the exact name. Open with at least three backticks or tildes; close on a separate line with the same marker and no shorter a run, allowing only surrounding spaces/tabs. Enclosing code fences keep button-block examples literal. Both accept the same singleton or mixed JSON/CML matrix and share prompt/app routing; both wrappers also accept adjacent complete JSON/CML cells and matrices with optional separators, concatenating their normalized rows in source order. Cells remain singleton rows and nested horizontal rows retain their grouping. Native rows allow at most eight buttons and must fit one Rich Message chunk; invalid or incomplete blocks register nothing. Drafts hide action fences. HTML compatibility projects fenced controls into the footer. In-body clicks acknowledge without recoloring the Rich body; selected-style highlighting remains footer-only. One marker accepts a JSON object, adaptive JSON/CML matrix, or positional [Compact Matrix Literal](./compact-matrix-literal.md). Named JSON objects and positional cells may coexist in one matrix or row; separators are optional and one trailing comma is tolerated at matrix, row, and JSON-object boundaries. Top-level cells become full-width rows, while nested rows group one or more buttons horizontally without an artificial parser-width cap. CML uses `{value}`, `{label|prompt}`, prompt-only `{|prompt}`, or the corresponding three-atom form with `selected_style`; an omitted label uses the existing prompt-as-label fallback, and the optional third atom requires a non-empty prompt and accepts only `primary`, `success`, or `danger`. A fourth atom accepts `1` or `true` (disabled), and `0` or `false` (enabled), with exact lowercase spelling; an omitted fourth position stays enabled, and the third atom may be empty in this form (`{|Next||1}`). JSON uses boolean `disabled`. Disabled cells need no prompt or selected style: `{Next|||1}` is label-only and `{|||1}` is blank (JSON `{"label":"Next","disabled":true}` and `{"disabled":true}`). The Telegram renderer supplies a non-breaking space only when the label is empty. Disabled cells stay visible but carry `disabled: {}` instead of callback data and register no prompt or bound action; invalid disabled values reject the candidate matrix. It trims atom boundaries and supports only the minimal escapes `\|`, `\}`, and `\\`. Prefer one matrix comment for multiple buttons. Use JSON `label` plus `prompt`, or `value` when both strings are identical. Action markers are colon-free; colon-prefixed payloads are rejected. HTML action comments activate regardless of surrounding Markdown; escape their opening delimiter for literal examples. Button fences require standalone delimiter lines outside enclosing code examples and quote/list prefixes, but allow horizontal indentation. Bare JSON/CML in prose never activates.
79
81
 
80
82
  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.
81
83
 
@@ -869,6 +869,8 @@ export function createTelegramLiveTargetWorkObserver<TContext>(deps: {
869
869
 
870
870
  interface TelegramCommandsAndToolsBindingDeps {
871
871
  pi: Pi.ExtensionAPI;
872
+ handleInternalAction:
873
+ Commands.TelegramSessionActionRuntime["handleInternalAction"];
872
874
  agentDir: string;
873
875
  configStore: Config.TelegramConfigStore;
874
876
  persistConfig: (config?: Config.TelegramConfig) => Promise<void>;
@@ -926,6 +928,7 @@ interface TelegramCommandsAndToolsBindingDeps {
926
928
 
927
929
  export function registerTelegramCommandsAndTools({
928
930
  pi,
931
+ handleInternalAction,
929
932
  agentDir,
930
933
  configStore,
931
934
  persistConfig,
@@ -1023,6 +1026,7 @@ export function registerTelegramCommandsAndTools({
1023
1026
  );
1024
1027
  };
1025
1028
  Commands.registerTelegramBridgeCommands(pi, {
1029
+ handleInternalAction,
1026
1030
  promptForConfig: async (ctx, profileName) => {
1027
1031
  const nextProfileName = profileName ?? undefined;
1028
1032
  if (profileName && !Config.isValidTelegramProfileName(profileName)) {
@@ -625,6 +625,7 @@ export interface TelegramBridgeCommandStartPollingResult {
625
625
  }
626
626
 
627
627
  export interface TelegramBridgeCommandRegistrationDeps {
628
+ handleInternalAction?: TelegramSessionActionRuntime["handleInternalAction"];
628
629
  promptForConfig: (
629
630
  ctx: ExtensionCommandContext,
630
631
  profileName?: string,
@@ -790,6 +791,7 @@ export function registerTelegramBridgeCommands(
790
791
  pi: ExtensionAPI,
791
792
  deps: TelegramBridgeCommandRegistrationDeps,
792
793
  ): void {
794
+ const handleInternalAction = deps.handleInternalAction;
793
795
  pi.registerCommand("telegram-setup", {
794
796
  description: "<profile> — Configure Telegram bot token",
795
797
  handler: async (args, ctx) => {
@@ -805,9 +807,21 @@ export function registerTelegramBridgeCommands(
805
807
  ctx.ui.notify(deps.getStatusLines({ verbose }).join("\n"), "info");
806
808
  },
807
809
  });
808
- pi.registerCommand("telegram-connect", {
810
+ pi.registerCommand(TELEGRAM_CONNECT_COMMAND_NAME, {
809
811
  description: "<profile> — Start Telegram bridge",
810
812
  handler: async (args, ctx) => {
813
+ const words = args.trim().split(/\s+/);
814
+ // Service dispatch never inherits profile activation or explicit-connect recovery authority.
815
+ if (words.some(word =>
816
+ word.toLowerCase().startsWith(TELEGRAM_INTERNAL_COMMAND_FLAG),
817
+ )) {
818
+ if (words[0] !== TELEGRAM_INTERNAL_COMMAND_FLAG || !handleInternalAction) {
819
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
820
+ return;
821
+ }
822
+ await handleInternalAction(words.slice(1).join(" "), ctx);
823
+ return;
824
+ }
811
825
  const sessionGeneration = deps.getSessionGeneration?.();
812
826
  let intentId: string | undefined;
813
827
  // Pi context getters throw after replacement; check plain intent/generation first.
@@ -4183,18 +4197,16 @@ async function handleTelegramCommandRuntime<
4183
4197
  );
4184
4198
  }
4185
4199
 
4186
- export const TELEGRAM_INTERNAL_COMMAND_NAME = "telegram-internal";
4187
- export const TELEGRAM_INTERNAL_COMMAND_DESCRIPTION =
4188
- "Internal Telegram command cannot be run manually";
4200
+ export const TELEGRAM_CONNECT_COMMAND_NAME = "telegram-connect";
4201
+ export const TELEGRAM_INTERNAL_COMMAND_FLAG = "--internal";
4189
4202
  export const TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE =
4190
- "This internal Telegram command cannot be run manually.";
4203
+ "This internal Telegram action cannot be run manually.";
4191
4204
 
4192
4205
  function delayTelegramSessionAction(delayMs: number): Promise<void> {
4193
4206
  return new Promise<void>((resolve) => setTimeout(resolve, delayMs));
4194
4207
  }
4195
4208
 
4196
4209
  export interface TelegramSessionActionRuntimeDeps {
4197
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4198
4210
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4199
4211
  notifyResult: (
4200
4212
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4277,7 +4289,6 @@ function createTelegramSessionReplacementSettlementRuntime<TContext>(deps: {
4277
4289
  }
4278
4290
 
4279
4291
  export interface TelegramSessionActionAssemblyDeps {
4280
- registerCommand: Pi.ExtensionAPI["registerCommand"];
4281
4292
  sendUserMessage: Pi.ExtensionAPI["sendUserMessage"];
4282
4293
  store: {
4283
4294
  load: () => Promise<void>;
@@ -4359,7 +4370,6 @@ export function createTelegramSessionActionAssembly(
4359
4370
  report(new Error("Telegram new-session result delivery failed."));
4360
4371
  };
4361
4372
  const action = createTelegramSessionActionRuntime({
4362
- registerCommand: deps.registerCommand,
4363
4373
  sendUserMessage: deps.sendUserMessage,
4364
4374
  notifyResult(target, result) {
4365
4375
  return sendTerminalResult(target, result);
@@ -4491,7 +4501,10 @@ type TelegramPendingInternalAction = {
4491
4501
  };
4492
4502
 
4493
4503
  export interface TelegramSessionActionRuntime {
4494
- register: () => void;
4504
+ handleInternalAction: (
4505
+ args: string,
4506
+ ctx: Pi.ExtensionCommandContext,
4507
+ ) => Promise<void>;
4495
4508
  scheduleAfterUpdate: (
4496
4509
  updateId: number,
4497
4510
  target: { chatId: number; threadId?: number; messageId: number },
@@ -4512,7 +4525,6 @@ export function createTelegramSessionActionRuntime(
4512
4525
  }
4513
4526
  | undefined;
4514
4527
  let pendingAction: TelegramPendingInternalAction | undefined;
4515
- let registered = false;
4516
4528
 
4517
4529
  const reportFailure = (error: unknown): void => {
4518
4530
  try {
@@ -4523,37 +4535,34 @@ export function createTelegramSessionActionRuntime(
4523
4535
  };
4524
4536
 
4525
4537
  return {
4526
- register() {
4527
- if (registered) return;
4528
- registered = true;
4529
- deps.registerCommand(TELEGRAM_INTERNAL_COMMAND_NAME, {
4530
- description: TELEGRAM_INTERNAL_COMMAND_DESCRIPTION,
4531
- handler: async (args, ctx) => {
4532
- const action = pendingAction;
4533
- if (!action || args.trim() !== action.token) {
4534
- ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4535
- return;
4536
- }
4537
- pendingAction = undefined;
4538
- switch (action.kind) {
4539
- case "replace-session":
4540
- try {
4541
- await deps.prepareReplacement?.(
4542
- ctx,
4543
- action.updateId,
4544
- action.target,
4545
- );
4546
- const result = await ctx.newSession();
4547
- if (result.cancelled)
4548
- await deps.notifyResult(action.target, "cancelled");
4549
- } catch (error) {
4550
- reportFailure(error);
4551
- await deps.notifyResult(action.target, "failure");
4552
- }
4553
- return;
4538
+ async handleInternalAction(args, ctx) {
4539
+ const action = pendingAction;
4540
+ const [operation, token, ...extra] = args.trim().split(/\s+/);
4541
+ if (
4542
+ !action || operation !== "new" ||
4543
+ token !== action.token || extra.length !== 0
4544
+ ) {
4545
+ ctx.ui.notify(TELEGRAM_INTERNAL_MANUAL_USE_MESSAGE, "warning");
4546
+ return;
4547
+ }
4548
+ pendingAction = undefined;
4549
+ switch (action.kind) {
4550
+ case "replace-session":
4551
+ try {
4552
+ await deps.prepareReplacement?.(
4553
+ ctx,
4554
+ action.updateId,
4555
+ action.target,
4556
+ );
4557
+ const result = await ctx.newSession();
4558
+ if (result.cancelled)
4559
+ await deps.notifyResult(action.target, "cancelled");
4560
+ } catch (error) {
4561
+ reportFailure(error);
4562
+ await deps.notifyResult(action.target, "failure");
4554
4563
  }
4555
- },
4556
- });
4564
+ return;
4565
+ }
4557
4566
  },
4558
4567
  scheduleAfterUpdate(updateId, target) {
4559
4568
  if (pendingUpdateId !== undefined || pendingAction !== undefined)
@@ -4572,9 +4581,10 @@ export function createTelegramSessionActionRuntime(
4572
4581
  pendingAction = { kind: "replace-session", token, updateId, target };
4573
4582
  void Promise.resolve()
4574
4583
  .then(() =>
4575
- deps.sendUserMessage(`/${TELEGRAM_INTERNAL_COMMAND_NAME} ${token}`, {
4576
- expandPromptTemplates: true,
4577
- }),
4584
+ deps.sendUserMessage(
4585
+ `/${TELEGRAM_CONNECT_COMMAND_NAME} ${TELEGRAM_INTERNAL_COMMAND_FLAG} new ${token}`,
4586
+ { expandPromptTemplates: true },
4587
+ ),
4578
4588
  )
4579
4589
  .catch((error) => {
4580
4590
  pendingAction = undefined;
@@ -92,7 +92,6 @@ export default function (pi: Pi.ExtensionAPI) {
92
92
  getCommands,
93
93
  getThinkingLevel,
94
94
  sendUserMessage,
95
- registerCommand,
96
95
  setActiveTools,
97
96
  setModel,
98
97
  setThinkingLevel,
@@ -310,7 +309,6 @@ export default function (pi: Pi.ExtensionAPI) {
310
309
  captureAuthority: captureTelegramStateAuthority,
311
310
  });
312
311
  const sessionActionAssembly = Commands.createTelegramSessionActionAssembly({
313
- registerCommand,
314
312
  sendUserMessage,
315
313
  store: threadStore,
316
314
  getProfileName: configStore.getActiveProfileName,
@@ -2833,9 +2831,9 @@ export default function (pi: Pi.ExtensionAPI) {
2833
2831
  },
2834
2832
  }.reset,
2835
2833
  );
2836
- sessionActionsRuntime.register();
2837
2834
  Bindings.registerTelegramCommandsAndTools({
2838
2835
  pi,
2836
+ handleInternalAction: sessionActionsRuntime.handleInternalAction,
2839
2837
  agentDir: Paths.resolveAgentDir(),
2840
2838
  configStore,
2841
2839
  persistConfig: persistTelegramConfigWithSync,
@@ -15,7 +15,7 @@ import {
15
15
  parseTelegramActionPayloadRows,
16
16
  parseTelegramButtonPayloadRows,
17
17
  replaceTelegramButtonFences,
18
- replaceTopLevelHtmlComments,
18
+ replaceTelegramHtmlComments,
19
19
  } from "./outbound-markup.ts";
20
20
  import {
21
21
  type PendingTelegramTurn,
@@ -237,8 +237,21 @@ export function planTelegramButtonReply(
237
237
  ),
238
238
  );
239
239
  };
240
+ const footerKeyboard: TelegramOutboundButtonMarkup["inline_keyboard"] = [];
241
+ const stripped = replaceTelegramHtmlComments(markdown, (comment) => {
242
+ const command = "telegram_button";
243
+ const normalizedContent = comment.content
244
+ .replace(/^\s+/, "")
245
+ .replace(/^!/, "");
246
+ if (!normalizedContent.startsWith(command)) return comment.raw;
247
+ const payloadRows = parseTelegramActionPayloadRows(comment, command);
248
+ if (!payloadRows) return "";
249
+ const rows = buildRows(payloadRows, false);
250
+ if (rows) footerKeyboard.push(...rows);
251
+ return "";
252
+ });
240
253
  const withRichButtons = replaceTelegramButtonFences(
241
- markdown,
254
+ stripped,
242
255
  (payload, closed) => {
243
256
  if (!closed) return "";
244
257
  const payloadRows = parseTelegramButtonPayloadRows(payload);
@@ -253,19 +266,8 @@ export function planTelegramButtonReply(
253
266
  return `\n${rows.map(renderTelegramRichButtonRow).join("\n\n")}\n`;
254
267
  },
255
268
  );
256
- const stripped = replaceTopLevelHtmlComments(withRichButtons, (comment) => {
257
- const command = "telegram_button";
258
- const normalizedContent = comment.content
259
- .replace(/^\s+/, "")
260
- .replace(/^!/, "");
261
- if (!normalizedContent.startsWith(command)) return comment.raw;
262
- const payloadRows = parseTelegramActionPayloadRows(comment, command);
263
- if (!payloadRows) return "";
264
- const rows = buildRows(payloadRows, false);
265
- if (rows) keyboard.push(...rows);
266
- return "";
267
- });
268
- const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(stripped);
269
+ keyboard.push(...footerKeyboard);
270
+ const visibleMarkdown = normalizeMarkdownAfterButtonExtraction(withRichButtons);
269
271
  return {
270
272
  markdown:
271
273
  keyboard.length > 0 && !visibleMarkdown
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Telegram outbound markup parsing helpers
3
3
  * Zones: telegram outbound, assistant markup
4
- * Owns top-level assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
4
+ * Owns delimiter-based assistant action comment extraction, attribute parsing, and markup stripping shared by voice and outbound delivery
5
5
  */
6
6
 
7
7
  import { isWireRecord as isTelegramActionPayload } from "./wire.ts";
8
8
 
9
- export interface TelegramTopLevelHtmlComment {
9
+ export interface TelegramHtmlComment {
10
10
  raw: string;
11
11
  content: string;
12
12
  start: number;
@@ -34,7 +34,7 @@ function getMarkdownLineText(
34
34
  function getTopLevelOpeningFence(
35
35
  line: string,
36
36
  ): TelegramTopLevelFenceState | undefined {
37
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
37
+ const match = line.match(/^[ \t]*(`{3,}|~{3,})/);
38
38
  const sequence = match?.[1];
39
39
  if (!sequence) return undefined;
40
40
  return {
@@ -47,7 +47,7 @@ function isTopLevelClosingFence(
47
47
  line: string,
48
48
  fence: TelegramTopLevelFenceState,
49
49
  ): boolean {
50
- const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
50
+ const match = line.match(/^[ \t]*(`{3,}|~{3,})[ \t]*$/);
51
51
  const sequence = match?.[1];
52
52
  return (
53
53
  !!sequence &&
@@ -56,38 +56,21 @@ function isTopLevelClosingFence(
56
56
  );
57
57
  }
58
58
 
59
- export function collectTopLevelHtmlComments(markdown: string): {
60
- comments: TelegramTopLevelHtmlComment[];
59
+ export function collectTelegramHtmlComments(markdown: string): {
60
+ comments: TelegramHtmlComment[];
61
61
  openCommentStart?: number;
62
62
  } {
63
- const comments: TelegramTopLevelHtmlComment[] = [];
63
+ const comments: TelegramHtmlComment[] = [];
64
64
  let offset = 0;
65
- let fence: TelegramTopLevelFenceState | undefined;
66
65
  while (offset < markdown.length) {
67
- const lineEnd = getMarkdownLineEnd(markdown, offset);
68
- const line = getMarkdownLineText(markdown, offset, lineEnd);
69
- if (fence) {
70
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
71
- offset = lineEnd;
72
- continue;
73
- }
74
- const nextFence = getTopLevelOpeningFence(line);
75
- if (nextFence) {
76
- fence = nextFence;
77
- offset = lineEnd;
78
- continue;
79
- }
80
- if (line.startsWith("<!--")) {
81
- const closeIndex = markdown.indexOf("-->", offset + 4);
82
- if (closeIndex === -1) return { comments, openCommentStart: offset };
83
- const end = closeIndex + 3;
84
- const raw = markdown.slice(offset, end);
85
- const content = raw.slice(4, -3);
86
- comments.push({ raw, content, start: offset, end });
87
- offset = getMarkdownLineEnd(markdown, end);
88
- continue;
89
- }
90
- offset = lineEnd;
66
+ const start = markdown.indexOf("<!--", offset);
67
+ if (start === -1) break;
68
+ const closeIndex = markdown.indexOf("-->", start + 4);
69
+ if (closeIndex === -1) return { comments, openCommentStart: start };
70
+ const end = closeIndex + 3;
71
+ const raw = markdown.slice(start, end);
72
+ comments.push({ raw, content: raw.slice(4, -3), start, end });
73
+ offset = end;
91
74
  }
92
75
  return { comments };
93
76
  }
@@ -122,7 +105,7 @@ export function replaceTelegramButtonFences(
122
105
  continue;
123
106
  } else {
124
107
  fence = getTopLevelOpeningFence(line);
125
- if (fence && /^```telegram_button[ \t]*$/.test(line)) {
108
+ if (fence && /^[ \t]*(?:`{3,}|~{3,})[ \t]*telegram_button[ \t]*$/.test(line)) {
126
109
  actionStart = offset;
127
110
  contentStart = end;
128
111
  }
@@ -136,11 +119,11 @@ export function replaceTelegramButtonFences(
136
119
  return result + markdown.slice(copied);
137
120
  }
138
121
 
139
- export function replaceTopLevelHtmlComments(
122
+ export function replaceTelegramHtmlComments(
140
123
  markdown: string,
141
- replacer: (comment: TelegramTopLevelHtmlComment) => string,
124
+ replacer: (comment: TelegramHtmlComment) => string,
142
125
  ): string {
143
- const { comments } = collectTopLevelHtmlComments(markdown);
126
+ const { comments } = collectTelegramHtmlComments(markdown);
144
127
  if (comments.length === 0) return markdown;
145
128
  let result = "";
146
129
  let offset = 0;
@@ -152,32 +135,9 @@ export function replaceTopLevelHtmlComments(
152
135
  return result + markdown.slice(offset);
153
136
  }
154
137
 
155
- function findTopLevelOpenOrPartialHtmlCommentIndex(markdown: string): number {
156
- const { openCommentStart } = collectTopLevelHtmlComments(markdown);
157
- if (openCommentStart !== undefined) return openCommentStart;
158
- let offset = 0;
159
- let fence: TelegramTopLevelFenceState | undefined;
160
- while (offset < markdown.length) {
161
- const lineEnd = getMarkdownLineEnd(markdown, offset);
162
- const line = getMarkdownLineText(markdown, offset, lineEnd);
163
- const isLastLine = lineEnd >= markdown.length;
164
- if (fence) {
165
- if (isTopLevelClosingFence(line, fence)) fence = undefined;
166
- offset = lineEnd;
167
- continue;
168
- }
169
- const nextFence = getTopLevelOpeningFence(line);
170
- if (nextFence) {
171
- fence = nextFence;
172
- offset = lineEnd;
173
- continue;
174
- }
175
- if (isLastLine && (line === "<" || line === "<!" || line === "<!-")) {
176
- return offset;
177
- }
178
- offset = lineEnd;
179
- }
180
- return -1;
138
+ function findOpenOrPartialHtmlCommentIndex(markdown: string): number {
139
+ const start = markdown.indexOf("<!--");
140
+ return start >= 0 ? start : (markdown.match(/<(?:!-?)?$/u)?.index ?? -1);
181
141
  }
182
142
 
183
143
  function parseTolerantTelegramAttributes(
@@ -263,7 +223,7 @@ function blankTelegramActionSpan(
263
223
  * with every structured span blanked; implausible `[` starts are skipped without a parse attempt.
264
224
  */
265
225
  function scanTelegramActionComment<T>(
266
- comment: TelegramTopLevelHtmlComment,
226
+ comment: TelegramHtmlComment,
267
227
  command: string,
268
228
  parseStructured: (content: string, offset: number) => T | undefined,
269
229
  ): { payload: T } | { attributeEnvelope: string } | undefined {
@@ -287,7 +247,7 @@ function scanTelegramActionComment<T>(
287
247
  }
288
248
 
289
249
  function parseTelegramActionPayload(
290
- comment: TelegramTopLevelHtmlComment,
250
+ comment: TelegramHtmlComment,
291
251
  command: string,
292
252
  ): Record<string, unknown> | undefined {
293
253
  const scanned = scanTelegramActionComment(
@@ -594,7 +554,7 @@ export function parseTelegramButtonPayloadRows(
594
554
  }
595
555
 
596
556
  export function parseTelegramActionPayloadRows(
597
- comment: TelegramTopLevelHtmlComment,
557
+ comment: TelegramHtmlComment,
598
558
  command: string,
599
559
  ): Record<string, unknown>[][] | undefined {
600
560
  const scanned = scanTelegramActionComment(
@@ -665,7 +625,7 @@ export function stripTelegramCommentMarkupForPreview(markdown: string): string {
665
625
  replaceTelegramButtonFences(markdown, () => ""),
666
626
  );
667
627
  const openBlockIndex =
668
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
628
+ findOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
669
629
  const previewMarkdown =
670
630
  openBlockIndex >= 0
671
631
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -678,7 +638,7 @@ export function stripTelegramCommentMarkupForDelivery(
678
638
  ): string {
679
639
  const withoutClosedBlocks = stripTelegramHtmlCommentBlocks(markdown);
680
640
  const openBlockIndex =
681
- findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
641
+ findOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
682
642
  const deliveryMarkdown =
683
643
  openBlockIndex >= 0
684
644
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -716,7 +676,7 @@ export function planTelegramVoiceReply(
716
676
  const voiceReplies: TelegramVoiceReplyItem[] = [];
717
677
  let lang: string | undefined;
718
678
  let rate: string | undefined;
719
- const stripped = replaceTopLevelHtmlComments(markdown, (comment) => {
679
+ const stripped = replaceTelegramHtmlComments(markdown, (comment) => {
720
680
  const command = "telegram_voice";
721
681
  const normalizedContent = comment.content
722
682
  .replace(/^\s+/, "")
@@ -849,11 +849,11 @@ export function createTelegramOutboundReplyPlanner(
849
849
  ...(options?.binding ? { binding: options.binding } : {}),
850
850
  });
851
851
 
852
- // Button replies can also contain <!-- telegram_voice --> markup
853
- const voiceReply = planTelegramVoiceReply(buttonReply.markdown);
852
+ // Extract voice from the original text before button-fence rendering consumes it.
853
+ const voiceReply = planTelegramVoiceReply(markdown);
854
854
 
855
855
  return {
856
- markdown: voiceReply.markdown,
856
+ markdown: stripTelegramCommentMarkupForDelivery(buttonReply.markdown),
857
857
  ...(buttonReply.replyMarkup
858
858
  ? { replyMarkup: buttonReply.replyMarkup }
859
859
  : {}),