@llblab/pi-telegram 0.6.2 → 0.6.3

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/README.md CHANGED
@@ -135,11 +135,11 @@ If you ask pi for a file or generated artifact (e.g., _"generate a shell script
135
135
 
136
136
  ### Assistant-Authored Outbound Actions
137
137
 
138
- Assistant replies can include hidden outbound blocks. `telegram_voice` and `telegram_button` are not pi tools; they are assistant-authored HTML comments that the bridge removes from Telegram text and handles after `agent_end`. Action comments are recognized only as top-level column-zero blocks outside fenced code, quotes, and lists, so documentation examples remain literal. This is faster than agent-side tool calls because the agent only writes correctly formatted Markdown in its normal answer; the extension builds the configured voice pipeline, button markup, and callback routing itself without registering or invoking extra transport/TTS/text-to-OGG tools.
138
+ Assistant replies can include hidden outbound blocks. `telegram_voice` and `telegram_button` are not pi tools; they are assistant-authored HTML comments that the bridge removes from Telegram text and handles after `agent_end`. Recognized blocks must start at column zero on a top-level line outside fenced code, quotes, and lists, so documentation examples remain literal. The agent writes normal Markdown; the extension owns voice generation, button markup, callback routing, and delivery.
139
139
 
140
140
  #### Voice
141
141
 
142
- Voice blocks synthesize their body and upload it as a native Telegram `sendVoice` OGG/Opus message. The body may be a concise companion summary, but it does not have to follow that format; write the text you want spoken and keep it TTS-friendly:
142
+ Voice blocks synthesize their text and upload it as a native Telegram `sendVoice` OGG/Opus message. Use body form for multiline text, `text="..."` for explicit one-line text with optional attributes, and the colon shorthand for a one-line voice with no attributes. The spoken text may be a concise companion summary, but it does not have to follow that format; write what you want spoken and keep it TTS-friendly:
143
143
 
144
144
  ```md
145
145
  Full technical answer stays readable as text.
@@ -147,6 +147,10 @@ Full technical answer stays readable as text.
147
147
  <!-- telegram_voice lang=ru rate=+30%
148
148
  Text to synthesize as a Telegram voice message.
149
149
  -->
150
+
151
+ <!-- telegram_voice lang=ru rate=+30% text="Short spoken companion summary." -->
152
+
153
+ <!-- telegram_voice: Short spoken companion summary. -->
150
154
  ```
151
155
 
152
156
  Outbound voice is disabled unless a matching `outboundHandlers[]` entry is configured. Multiple `telegram_voice` blocks in one reply are synthesized and sent independently, preserving each block's attributes. The bridge uses the same [command-template contract](./docs/command-templates.md) as inbound attachment handlers: split the template into args, substitute placeholders, execute without a shell, and use stdout as the result channel for a single template.
@@ -171,19 +175,21 @@ A TTS plus MP3-to-OGG setup can be expressed as `template: [...]`. The bridge pr
171
175
 
172
176
  #### Buttons
173
177
 
174
- Button blocks attach inline quick replies to the final text. Use one independent `telegram_button` block per action; its `label` is shown in Telegram and its body is sent back to pi when tapped. If the prompt should equal the label, the body can be omitted:
178
+ Button blocks attach inline quick replies to the final text. Use one independent `telegram_button` block per action. If the prompt should equal the label, use the colon shorthand. If the prompt differs, use the inline `prompt="..."` attribute for one-line prompts or the body form for multiline prompts:
175
179
 
176
180
  ```md
177
181
  I can continue.
178
182
 
179
- <!-- telegram_button label="Continue"
180
- Continue with the current plan.
183
+ <!-- telegram_button label=Continue prompt="Continue with the current plan." -->
184
+
185
+ <!-- telegram_button label="Show risks"
186
+ List the main risks first.
181
187
  -->
182
188
 
183
- <!-- telegram_button label="OK" -->
189
+ <!-- telegram_button: OK -->
184
190
  ```
185
191
 
186
- Button prompts are routed back into the normal Telegram queue as prompt turns. Outbound handler details are documented in [`docs/outbound-handlers.md`](./docs/outbound-handlers.md).
192
+ 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).
187
193
 
188
194
  ## Streaming
189
195
 
@@ -23,23 +23,23 @@ Naming rule: because the repository already scopes this codebase to Telegram, ex
23
23
 
24
24
  Current runtime areas use these ownership boundaries:
25
25
 
26
- | Domain | Owns |
27
- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
- | `index.ts` | Single composition root for live pi/Telegram ports, session state, API-bound transport adapters, and status updates |
29
- | `api` | Bot API transport shapes/helpers, retries, file download, temp-dir lifecycle, inbound limits, chat actions, lazy bot-token clients, runtime error recording |
30
- | `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
31
- | `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
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 |
34
- | `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
35
- | `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
36
- | `model` / `menu` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, inline status/model/thinking UI, slash commands, bot command registration |
37
- | `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
38
- | `outbound-handlers` | Assistant-authored outbound comments, generated reply artifacts, inline-keyboard callbacks, and post-`agent_end` outbound action delivery |
39
- | `attachments` | `telegram_attach` registration, outbound attachment queueing, stat/limit checks, photo/document delivery classification |
40
- | `status` | Status-bar/status-message rendering, queue-lane status views, redacted runtime event ring, grouped pi diagnostics |
41
- | `lifecycle` / `prompts` / `pi` | pi hook registration, Telegram-specific before-agent prompt injection, centralized direct pi SDK imports and context adapters |
42
- | `command-templates` | Portable shell-free command-template standard helpers, composition expansion, placeholder substitution, and executable resolution |
26
+ | Domain | Owns |
27
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28
+ | `index.ts` | Single composition root for live pi/Telegram ports, session state, API-bound transport adapters, and status updates |
29
+ | `api` | Bot API transport shapes/helpers, retries, file download, temp-dir lifecycle, inbound limits, chat actions, lazy bot-token clients, runtime error recording |
30
+ | `config` / `setup` | Persisted bot/session pairing state, authorization, first-user pairing, token prompting, env fallback, validation, config persistence |
31
+ | `locks` / `polling` | Singleton `locks.json` ownership, takeover/restart semantics, long-poll controller state, update offset persistence, poll-loop runtime wiring |
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 |
34
+ | `queue` | Queue item contracts, lane admission/order, stores, mutations, dispatch readiness/runtime, prompt/control enqueueing, session and agent/tool lifecycle sequencing |
35
+ | `runtime` | Session-local coordination primitives: counters, lifecycle flags, setup guard, abort handler, typing-loop timers, prompt-dispatch flags, agent-end reset binding |
36
+ | `model` / `menu` / `commands` | Model identity/thinking levels, scoped model resolution, in-flight switching, inline status/model/thinking UI, slash commands, bot command registration |
37
+ | `preview` / `replies` / `rendering` | Preview lifecycle/transports, final reply delivery and reply parameters, Telegram HTML Markdown rendering, chunking, stable-preview snapshots |
38
+ | `outbound-handlers` | Assistant-authored outbound comments, generated reply artifacts, inline-keyboard callbacks, and post-`agent_end` outbound action delivery |
39
+ | `attachments` | `telegram_attach` registration, outbound attachment queueing, stat/limit checks, photo/document delivery classification |
40
+ | `status` | Status-bar/status-message rendering, queue-lane status views, redacted runtime event ring, grouped pi diagnostics |
41
+ | `lifecycle` / `prompts` / `pi` | pi hook registration, Telegram-specific before-agent prompt injection, centralized direct pi SDK imports and context adapters |
42
+ | `command-templates` | Portable shell-free command-template standard helpers, composition expansion, placeholder substitution, and executable resolution |
43
43
 
44
44
  Boundary invariants:
45
45
 
@@ -155,7 +155,7 @@ Telegram prompt responses use explicit delivery context to attach outbound text,
155
155
 
156
156
  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.
157
157
 
158
- 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 the block body 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, top-level `timeout` wraps the whole sequence, 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, or the button label when the body is omitted, as a normal Telegram prompt turn. 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.
158
+ 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, top-level `timeout` wraps the whole sequence, 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.
159
159
 
160
160
  ## Interactive Controls
161
161
 
@@ -49,9 +49,13 @@ Full text answer stays here.
49
49
  <!-- telegram_voice lang=ru rate=+30%
50
50
  Text to synthesize as a Telegram voice message.
51
51
  -->
52
+
53
+ <!-- telegram_voice lang=ru rate=+30% text="Short spoken companion summary." -->
54
+
55
+ <!-- telegram_voice: Short spoken companion summary. -->
52
56
  ```
53
57
 
54
- The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` block to `type: "voice"`, generates one file per block, and sends each file as an independent Telegram-native voice message. The opening `<!-- telegram_voice` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown.
58
+ The bridge strips the comment from Telegram text. On `agent_end`, it maps each `telegram_voice` block to `type: "voice"`, generates one file per block, and sends each file as an independent Telegram-native voice message. The opening `<!-- telegram_voice` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown. Body-form comments leave the opening line unclosed until the body-ending `-->`; closed heads can use `text="..."` for explicit one-line spoken text.
55
59
 
56
60
  ## Built-In Voice Placeholders
57
61
 
@@ -59,7 +63,7 @@ Voice outbound handlers receive these runtime placeholders:
59
63
 
60
64
  | Placeholder | Value |
61
65
  | ----------- | -------------------------------------------------------- |
62
- | `{text}` | Voice block body |
66
+ | `{text}` | Voice text from body, `text="..."`, or colon shorthand |
63
67
  | `{lang}` | Optional markup override such as `lang=ru` |
64
68
  | `{rate}` | Optional markup override such as `rate=+30%` |
65
69
  | `{mp3}` | Flat temp artifact path under `~/.pi/agent/tmp/telegram` |
@@ -75,26 +79,27 @@ For one-step `template` handlers, stdout remains the default result channel: the
75
79
 
76
80
  ## Buttons Markup
77
81
 
78
- Assistant replies can include independent button blocks. The block body is the prompt sent back to pi when the user taps the button; omit the body when the prompt should equal the label:
82
+ Assistant replies can include independent button blocks. The prompt is sent back to pi when the user taps the button; use the colon shorthand when the prompt should equal the label, `prompt="..."` for one-line prompts, or the body form for multiline prompts:
79
83
 
80
84
  ```md
81
85
  I can continue.
82
86
 
83
- <!-- telegram_button label="OK"
84
- Continue with the current plan.
85
- -->
87
+ <!-- telegram_button label=Continue prompt="Continue with the current plan." -->
86
88
 
87
89
  <!-- telegram_button label="Show risks"
88
90
  List the main risks first.
89
91
  -->
90
92
 
91
- <!-- telegram_button label="Done" -->
93
+ <!-- telegram_button: Done -->
92
94
  ```
93
95
 
94
96
  Rules:
95
97
 
96
- - `telegram_button label="Label"` creates one independent button row whose prompt is the block body, or the label itself when the body is omitted.
98
+ - `telegram_button: Label` creates one independent label-only button row whose prompt equals the label.
99
+ - `telegram_button label="Label" prompt="Prompt"` creates one independent button row whose prompt is the `prompt` attribute.
100
+ - `telegram_button label="Label"` with a body creates one independent button row whose prompt is the block body.
97
101
  - The opening `<!-- telegram_button` marker must start at column zero on a top-level line outside fenced code, quotes, and lists; otherwise it is rendered as literal Markdown.
102
+ - Keep the canonical body form as `<!-- telegram_button label="Label"` + body + `-->`; closed heads must use `prompt="..."` or the colon shorthand to create a button.
98
103
  - Use one block per button; this mirrors HTML's singular element model and avoids a nested button DSL inside comments.
99
104
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
100
105
 
@@ -105,8 +110,8 @@ Buttons are built in and do not need a command template because they are pure Te
105
110
  The extension injects Telegram-specific system prompt guidance so agents know the fast path:
106
111
 
107
112
  - Write the full technical answer as normal Markdown.
108
- - Add `telegram_voice` when a Telegram-native voice message is useful; the block body is the text to synthesize and may be a companion summary, but no specific summary format is required.
109
- - Add `telegram_button label="..."` for quick replies that should come back as normal Telegram prompts.
113
+ - Add `telegram_voice` when a Telegram-native voice message is useful; use body text, `text="..."`, or colon shorthand for the text to synthesize. A companion summary is optional, no specific summary format is required.
114
+ - Add `telegram_button: ...` when label equals prompt, `telegram_button label="..." prompt="..."` for one-line prompts, or `telegram_button label="..."` with a body for multiline prompts. If the reply contains only button/voice comment blocks, add a short visible marker (for example `Choose one:`) before them so Telegram always has a visible parent message for attachment.
110
115
  - Do not call or register TTS/text-to-OGG/Telegram transport tools for voice or buttons; the bridge owns the configured outbound-handler pipeline and delivery.
111
116
 
112
117
  This keeps the agent focused on semantics and lets the bridge handle low-latency Telegram adaptation.
package/index.ts CHANGED
@@ -33,7 +33,10 @@ type RuntimeTelegramQueueItem = Queue.TelegramQueueItem<Pi.ExtensionContext>;
33
33
 
34
34
  export default function (pi: Pi.ExtensionAPI) {
35
35
  const piRuntime = Pi.createExtensionApiRuntimePorts(pi);
36
+ const { getThinkingLevel, sendUserMessage, setModel, setThinkingLevel } =
37
+ piRuntime;
36
38
  const bridgeRuntime = Runtime.createTelegramBridgeRuntime();
39
+ const { abort, lifecycle, queue, setup, typing } = bridgeRuntime;
37
40
  const configStore = Config.createTelegramConfigStore();
38
41
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
39
42
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
@@ -46,6 +49,11 @@ export default function (pi: Pi.ExtensionAPI) {
46
49
  const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
47
50
  getBotToken: configStore.getBotToken,
48
51
  });
52
+ const recordRuntimeEvent = runtimeEvents.record;
53
+ const getContextModel = Pi.getExtensionContextModel;
54
+ const isIdle = Pi.isExtensionContextIdle;
55
+ const hasPendingMessages = Pi.hasExtensionContextPendingMessages;
56
+ const compact = Pi.compactExtensionContext;
49
57
  const mediaGroupRuntime = Media.createTelegramMediaGroupController<
50
58
  Api.TelegramMessage,
51
59
  Pi.ExtensionContext
@@ -54,7 +62,7 @@ export default function (pi: Pi.ExtensionAPI) {
54
62
  Queue.createTelegramQueueStore<Pi.ExtensionContext>();
55
63
  const deferredQueueDispatchRuntime =
56
64
  Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
57
- recordRuntimeEvent: runtimeEvents.record,
65
+ recordRuntimeEvent,
58
66
  });
59
67
  const pollingControllerState = Polling.createTelegramPollingControllerState();
60
68
  const { getStatusLines, updateStatus } =
@@ -68,9 +76,9 @@ export default function (pi: Pi.ExtensionAPI) {
68
76
  ),
69
77
  getActiveSourceMessageIds: activeTurnRuntime.getSourceMessageIds,
70
78
  hasActiveTurn: activeTurnRuntime.has,
71
- hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
72
- isCompactionInProgress: bridgeRuntime.lifecycle.isCompactionInProgress,
73
- getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
79
+ hasDispatchPending: lifecycle.hasDispatchPending,
80
+ isCompactionInProgress: lifecycle.isCompactionInProgress,
81
+ getActiveToolExecutions: lifecycle.getActiveToolExecutions,
74
82
  hasPendingModelSwitch: pendingModelSwitchStore.has,
75
83
  getQueuedItems: telegramQueueStore.getQueuedItems,
76
84
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
@@ -81,16 +89,15 @@ export default function (pi: Pi.ExtensionAPI) {
81
89
  Pi.ExtensionContext,
82
90
  ActivePiModel
83
91
  >({
84
- getContextModel: Pi.getExtensionContextModel,
92
+ getContextModel,
85
93
  updateStatus,
86
94
  });
87
95
  const queueMutationRuntime =
88
96
  Queue.createTelegramQueueMutationController<Pi.ExtensionContext>({
89
97
  ...telegramQueueStore,
90
- getNextPriorityReactionOrder:
91
- bridgeRuntime.queue.getNextPriorityReactionOrder,
98
+ getNextPriorityReactionOrder: queue.getNextPriorityReactionOrder,
92
99
  incrementNextPriorityReactionOrder:
93
- bridgeRuntime.queue.incrementNextPriorityReactionOrder,
100
+ queue.incrementNextPriorityReactionOrder,
94
101
  updateStatus,
95
102
  });
96
103
  const attachmentHandlerRuntime =
@@ -99,7 +106,7 @@ export default function (pi: Pi.ExtensionAPI) {
99
106
  getHandlers: configStore.getAttachmentHandlers,
100
107
  execCommand: CommandTemplates.execCommandTemplate,
101
108
  getCwd: Pi.getExtensionContextCwd,
102
- recordRuntimeEvent: runtimeEvents.record,
109
+ recordRuntimeEvent,
103
110
  },
104
111
  );
105
112
 
@@ -119,19 +126,19 @@ export default function (pi: Pi.ExtensionAPI) {
119
126
  prepareTempDir,
120
127
  } = Api.createDefaultTelegramBridgeApiRuntime({
121
128
  getBotToken: configStore.getBotToken,
122
- recordRuntimeEvent: runtimeEvents.record,
129
+ recordRuntimeEvent,
123
130
  });
124
131
 
125
132
  // --- Message Delivery & Preview ---
126
133
 
127
134
  const promptDispatchRuntime =
128
135
  Runtime.createTelegramPromptDispatchRuntime<Pi.ExtensionContext>({
129
- lifecycle: bridgeRuntime.lifecycle,
130
- typing: bridgeRuntime.typing,
136
+ lifecycle,
137
+ typing,
131
138
  getDefaultChatId: activeTurnRuntime.getChatId,
132
139
  sendTypingAction,
133
140
  updateStatus,
134
- recordRuntimeEvent: runtimeEvents.record,
141
+ recordRuntimeEvent,
135
142
  });
136
143
 
137
144
  // --- Reply Runtime Wiring ---
@@ -152,17 +159,17 @@ export default function (pi: Pi.ExtensionAPI) {
152
159
  const dispatchNextQueuedTelegramTurn =
153
160
  Queue.createTelegramQueueDispatchRuntime<Pi.ExtensionContext>({
154
161
  ...telegramQueueStore,
155
- isCompactionInProgress: bridgeRuntime.lifecycle.isCompactionInProgress,
162
+ isCompactionInProgress: lifecycle.isCompactionInProgress,
156
163
  hasActiveTurn: activeTurnRuntime.has,
157
- hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
158
- isIdle: Pi.isExtensionContextIdle,
159
- hasPendingMessages: Pi.hasExtensionContextPendingMessages,
164
+ hasDispatchPending: lifecycle.hasDispatchPending,
165
+ isIdle,
166
+ hasPendingMessages,
160
167
  hasDispatchContext: deferredQueueDispatchRuntime.isBound,
161
168
  updateStatus,
162
169
  sendTextReply,
163
- recordRuntimeEvent: runtimeEvents.record,
170
+ recordRuntimeEvent,
164
171
  ...promptDispatchRuntime,
165
- sendUserMessage: piRuntime.sendUserMessage,
172
+ sendUserMessage,
166
173
  }).dispatchNext;
167
174
  const previewRuntime = Preview.createTelegramAssistantPreviewRuntime({
168
175
  getActiveTurn: activeTurnRuntime.get,
@@ -182,15 +189,15 @@ export default function (pi: Pi.ExtensionAPI) {
182
189
  Pi.ExtensionContext,
183
190
  Model.ScopedTelegramModel<ActivePiModel>
184
191
  >({
185
- isIdle: Pi.isExtensionContextIdle,
192
+ isIdle,
186
193
  getPendingModelSwitch: pendingModelSwitchStore.get,
187
194
  setPendingModelSwitch: pendingModelSwitchStore.set,
188
195
  getActiveTurn: activeTurnRuntime.get,
189
- getAbortHandler: bridgeRuntime.abort.getHandler,
190
- hasAbortHandler: bridgeRuntime.abort.hasHandler,
191
- getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
192
- allocateItemOrder: bridgeRuntime.queue.allocateItemOrder,
193
- allocateControlOrder: bridgeRuntime.queue.allocateControlOrder,
196
+ getAbortHandler: abort.getHandler,
197
+ hasAbortHandler: abort.hasHandler,
198
+ getActiveToolExecutions: lifecycle.getActiveToolExecutions,
199
+ allocateItemOrder: queue.allocateItemOrder,
200
+ allocateControlOrder: queue.allocateControlOrder,
194
201
  appendQueuedItem: queueMutationRuntime.append,
195
202
  updateStatus,
196
203
  });
@@ -201,12 +208,12 @@ export default function (pi: Pi.ExtensionAPI) {
201
208
  runtime: modelMenuRuntime,
202
209
  createSettingsManager: Pi.createSettingsManager,
203
210
  getActiveModel: currentModelRuntime.get,
204
- getThinkingLevel: piRuntime.getThinkingLevel,
211
+ getThinkingLevel,
205
212
  buildStatusHtml: Status.createTelegramStatusHtmlBuilder({
206
213
  getActiveModel: currentModelRuntime.get,
207
214
  }),
208
215
  storeModelMenuState: modelMenuRuntime.storeState,
209
- isIdle: Pi.isExtensionContextIdle,
216
+ isIdle,
210
217
  canOfferInFlightModelSwitch: modelSwitchController.canOfferInFlightSwitch,
211
218
  sendTextReply,
212
219
  editInteractiveMessage,
@@ -215,6 +222,39 @@ export default function (pi: Pi.ExtensionAPI) {
215
222
 
216
223
  // --- Polling ---
217
224
 
225
+ const inboundRouteRuntime = Routing.createTelegramInboundRouteRuntime<
226
+ Api.TelegramUpdate,
227
+ Api.TelegramMessage,
228
+ Api.TelegramCallbackQuery,
229
+ Pi.ExtensionContext,
230
+ ActivePiModel
231
+ >({
232
+ configStore,
233
+ bridgeRuntime,
234
+ activeTurnRuntime,
235
+ mediaGroupRuntime,
236
+ telegramQueueStore,
237
+ queueMutationRuntime,
238
+ modelMenuRuntime,
239
+ currentModelRuntime,
240
+ modelSwitchController,
241
+ menuActions,
242
+ buttonActionStore,
243
+ attachmentHandlerRuntime,
244
+ updateStatus,
245
+ dispatchNextQueuedTelegramTurn,
246
+ answerCallbackQuery,
247
+ sendTextReply,
248
+ setMyCommands,
249
+ downloadFile: downloadTelegramBridgeFile,
250
+ getThinkingLevel,
251
+ setThinkingLevel,
252
+ setModel,
253
+ isIdle,
254
+ hasPendingMessages,
255
+ compact,
256
+ recordRuntimeEvent,
257
+ });
218
258
  const pollingRuntime = Polling.createTelegramPollingControllerRuntime<
219
259
  Api.TelegramUpdate,
220
260
  Pi.ExtensionContext
@@ -225,42 +265,10 @@ export default function (pi: Pi.ExtensionAPI) {
225
265
  deleteWebhook,
226
266
  getUpdates,
227
267
  persistConfig: configStore.persist,
228
- handleUpdate: Routing.createTelegramInboundRouteRuntime<
229
- Api.TelegramUpdate,
230
- Api.TelegramMessage,
231
- Api.TelegramCallbackQuery,
232
- Pi.ExtensionContext,
233
- ActivePiModel
234
- >({
235
- configStore,
236
- bridgeRuntime,
237
- activeTurnRuntime,
238
- mediaGroupRuntime,
239
- telegramQueueStore,
240
- queueMutationRuntime,
241
- modelMenuRuntime,
242
- currentModelRuntime,
243
- modelSwitchController,
244
- menuActions,
245
- buttonActionStore,
246
- attachmentHandlerRuntime,
247
- updateStatus,
248
- dispatchNextQueuedTelegramTurn,
249
- answerCallbackQuery,
250
- sendTextReply,
251
- setMyCommands,
252
- downloadFile: downloadTelegramBridgeFile,
253
- getThinkingLevel: piRuntime.getThinkingLevel,
254
- setThinkingLevel: piRuntime.setThinkingLevel,
255
- setModel: piRuntime.setModel,
256
- isIdle: Pi.isExtensionContextIdle,
257
- hasPendingMessages: Pi.hasExtensionContextPendingMessages,
258
- compact: Pi.compactExtensionContext,
259
- recordRuntimeEvent: runtimeEvents.record,
260
- }).handleUpdate,
261
- stopTypingLoop: bridgeRuntime.typing.stop,
268
+ handleUpdate: inboundRouteRuntime.handleUpdate,
269
+ stopTypingLoop: typing.stop,
262
270
  updateStatus,
263
- recordRuntimeEvent: runtimeEvents.record,
271
+ recordRuntimeEvent,
264
272
  });
265
273
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
266
274
  lock: lockRuntime,
@@ -268,34 +276,35 @@ export default function (pi: Pi.ExtensionAPI) {
268
276
  startPolling: pollingRuntime.start,
269
277
  stopPolling: pollingRuntime.stop,
270
278
  updateStatus,
271
- recordRuntimeEvent: runtimeEvents.record,
279
+ recordRuntimeEvent,
280
+ });
281
+ const queueSessionLifecycle = Queue.createTelegramSessionLifecycleRuntime<
282
+ Pi.ExtensionContext,
283
+ RuntimeTelegramQueueItem,
284
+ ActivePiModel
285
+ >({
286
+ getCurrentModel: getContextModel,
287
+ loadConfig: configStore.load,
288
+ setQueuedItems: telegramQueueStore.setQueuedItems,
289
+ setCurrentModel: currentModelRuntime.set,
290
+ setPendingModelSwitch: pendingModelSwitchStore.set,
291
+ syncCounters: queue.syncCounters,
292
+ syncFlags: lifecycle.syncFlags,
293
+ bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind,
294
+ prepareTempDir,
295
+ updateStatus,
296
+ unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
297
+ clearPendingMediaGroups: mediaGroupRuntime.clear,
298
+ clearModelMenuState: modelMenuRuntime.clear,
299
+ getActiveTurnChatId: activeTurnRuntime.getChatId,
300
+ clearPreview: previewRuntime.clear,
301
+ clearActiveTurn: activeTurnRuntime.clear,
302
+ clearAbort: abort.clearHandler,
303
+ stopPolling: lockedPollingRuntime.suspend,
304
+ recordRuntimeEvent,
272
305
  });
273
306
  const sessionLifecycleRuntime = Lifecycle.appendTelegramLifecycleHooks(
274
- Queue.createTelegramSessionLifecycleRuntime<
275
- Pi.ExtensionContext,
276
- RuntimeTelegramQueueItem,
277
- ActivePiModel
278
- >({
279
- getCurrentModel: Pi.getExtensionContextModel,
280
- loadConfig: configStore.load,
281
- setQueuedItems: telegramQueueStore.setQueuedItems,
282
- setCurrentModel: currentModelRuntime.set,
283
- setPendingModelSwitch: pendingModelSwitchStore.set,
284
- syncCounters: bridgeRuntime.queue.syncCounters,
285
- syncFlags: bridgeRuntime.lifecycle.syncFlags,
286
- bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind,
287
- prepareTempDir,
288
- updateStatus,
289
- unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
290
- clearPendingMediaGroups: mediaGroupRuntime.clear,
291
- clearModelMenuState: modelMenuRuntime.clear,
292
- getActiveTurnChatId: activeTurnRuntime.getChatId,
293
- clearPreview: previewRuntime.clear,
294
- clearActiveTurn: activeTurnRuntime.clear,
295
- clearAbort: bridgeRuntime.abort.clearHandler,
296
- stopPolling: lockedPollingRuntime.suspend,
297
- recordRuntimeEvent: runtimeEvents.record,
298
- }),
307
+ queueSessionLifecycle,
299
308
  { onSessionStart: lockedPollingRuntime.onSessionStart },
300
309
  );
301
310
 
@@ -303,19 +312,19 @@ export default function (pi: Pi.ExtensionAPI) {
303
312
 
304
313
  Attachments.registerTelegramAttachmentTool(pi, {
305
314
  getActiveTurn: activeTurnRuntime.get,
306
- recordRuntimeEvent: runtimeEvents.record,
315
+ recordRuntimeEvent,
307
316
  });
308
317
 
309
318
  Commands.registerTelegramBridgeCommands(pi, {
310
319
  promptForConfig: Setup.createTelegramSetupPromptRuntime({
311
320
  getConfig: configStore.get,
312
321
  setConfig: configStore.set,
313
- setupGuard: bridgeRuntime.setup,
322
+ setupGuard: setup,
314
323
  getMe: Api.fetchTelegramBotIdentity,
315
324
  persistConfig: configStore.persist,
316
325
  startPolling: lockedPollingRuntime.start,
317
326
  updateStatus,
318
- recordRuntimeEvent: runtimeEvents.record,
327
+ recordRuntimeEvent,
319
328
  }),
320
329
  getStatusLines,
321
330
  reloadConfig: configStore.load,
@@ -327,68 +336,71 @@ export default function (pi: Pi.ExtensionAPI) {
327
336
 
328
337
  // --- Lifecycle Hooks ---
329
338
 
339
+ const agentEndResetter = Runtime.createTelegramAgentEndResetter({
340
+ abort,
341
+ typing,
342
+ clearActiveTurn: activeTurnRuntime.clear,
343
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
344
+ clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
345
+ clearDispatchPending: lifecycle.clearDispatchPending,
346
+ });
347
+ const queuedAttachmentSender =
348
+ Attachments.createTelegramQueuedAttachmentSender({
349
+ sendMultipart: callMultipart,
350
+ sendTextReply,
351
+ recordRuntimeEvent,
352
+ });
353
+ const outboundReplyPlanner =
354
+ OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore);
355
+ const outboundReplyArtifactSender =
356
+ OutboundHandlers.createTelegramOutboundReplyArtifactSender({
357
+ execCommand: CommandTemplates.execCommandTemplate,
358
+ sendMultipart: callMultipart,
359
+ sendTextReply,
360
+ getHandlers: configStore.getOutboundHandlers,
361
+ recordRuntimeEvent,
362
+ });
363
+ const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
364
+ Queue.PendingTelegramTurn,
365
+ Pi.ExtensionContext,
366
+ unknown
367
+ >({
368
+ setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(abort),
369
+ getQueuedItems: telegramQueueStore.getQueuedItems,
370
+ hasPendingDispatch: lifecycle.hasDispatchPending,
371
+ hasActiveTurn: activeTurnRuntime.has,
372
+ resetToolExecutions: lifecycle.resetActiveToolExecutions,
373
+ resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
374
+ setQueuedItems: telegramQueueStore.setQueuedItems,
375
+ clearDispatchPending: lifecycle.clearDispatchPending,
376
+ setActiveTurn: activeTurnRuntime.set,
377
+ createPreviewState: previewRuntime.resetState,
378
+ startTypingLoop: promptDispatchRuntime.startTypingLoop,
379
+ updateStatus,
380
+ getActiveTurn: activeTurnRuntime.get,
381
+ extractAssistant: Replies.extractLatestAssistantMessageText,
382
+ getPreserveQueuedTurnsAsHistory: lifecycle.shouldPreserveQueuedTurnsAsHistory,
383
+ resetRuntimeState: agentEndResetter,
384
+ dispatchNextQueuedTelegramTurn,
385
+ requestDeferredDispatchNextQueuedTelegramTurn:
386
+ deferredQueueDispatchRuntime.request,
387
+ clearPreview: previewRuntime.clear,
388
+ setPreviewPendingText: previewRuntime.setPendingText,
389
+ finalizeMarkdownPreview: previewRuntime.finalizeMarkdown,
390
+ sendMarkdownReply,
391
+ sendTextReply,
392
+ sendQueuedAttachments: queuedAttachmentSender,
393
+ planOutboundReply: outboundReplyPlanner,
394
+ sendOutboundReplyArtifacts: outboundReplyArtifactSender,
395
+ getActiveToolExecutions: lifecycle.getActiveToolExecutions,
396
+ setActiveToolExecutions: lifecycle.setActiveToolExecutions,
397
+ triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
398
+ });
330
399
  Lifecycle.registerTelegramLifecycleHooks(pi, {
331
400
  ...sessionLifecycleRuntime,
401
+ ...agentLifecycleHooks,
332
402
  onBeforeAgentStart: Prompts.createTelegramBeforeAgentStartHook(),
333
403
  onModelSelect: currentModelRuntime.onModelSelect,
334
- ...Queue.createTelegramAgentLifecycleHooks<
335
- Queue.PendingTelegramTurn,
336
- Pi.ExtensionContext,
337
- unknown
338
- >({
339
- setAbortHandler: Runtime.createTelegramContextAbortHandlerSetter(
340
- bridgeRuntime.abort,
341
- ),
342
- getQueuedItems: telegramQueueStore.getQueuedItems,
343
- hasPendingDispatch: bridgeRuntime.lifecycle.hasDispatchPending,
344
- hasActiveTurn: activeTurnRuntime.has,
345
- resetToolExecutions: bridgeRuntime.lifecycle.resetActiveToolExecutions,
346
- resetPendingModelSwitch: modelSwitchController.clearPendingSwitch,
347
- setQueuedItems: telegramQueueStore.setQueuedItems,
348
- clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
349
- setActiveTurn: activeTurnRuntime.set,
350
- createPreviewState: previewRuntime.resetState,
351
- startTypingLoop: promptDispatchRuntime.startTypingLoop,
352
- updateStatus,
353
- getActiveTurn: activeTurnRuntime.get,
354
- extractAssistant: Replies.extractLatestAssistantMessageText,
355
- getPreserveQueuedTurnsAsHistory:
356
- bridgeRuntime.lifecycle.shouldPreserveQueuedTurnsAsHistory,
357
- resetRuntimeState: Runtime.createTelegramAgentEndResetter({
358
- abort: bridgeRuntime.abort,
359
- typing: bridgeRuntime.typing,
360
- clearActiveTurn: activeTurnRuntime.clear,
361
- resetToolExecutions: bridgeRuntime.lifecycle.resetActiveToolExecutions,
362
- clearPendingModelSwitch: modelSwitchController.clearPendingSwitch,
363
- clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
364
- }),
365
- dispatchNextQueuedTelegramTurn,
366
- requestDeferredDispatchNextQueuedTelegramTurn:
367
- deferredQueueDispatchRuntime.request,
368
- clearPreview: previewRuntime.clear,
369
- setPreviewPendingText: previewRuntime.setPendingText,
370
- finalizeMarkdownPreview: previewRuntime.finalizeMarkdown,
371
- sendMarkdownReply,
372
- sendTextReply,
373
- sendQueuedAttachments: Attachments.createTelegramQueuedAttachmentSender({
374
- sendMultipart: callMultipart,
375
- sendTextReply,
376
- recordRuntimeEvent: runtimeEvents.record,
377
- }),
378
- planOutboundReply:
379
- OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore),
380
- sendOutboundReplyArtifacts:
381
- OutboundHandlers.createTelegramOutboundReplyArtifactSender({
382
- execCommand: CommandTemplates.execCommandTemplate,
383
- sendMultipart: callMultipart,
384
- sendTextReply,
385
- getHandlers: configStore.getOutboundHandlers,
386
- recordRuntimeEvent: runtimeEvents.record,
387
- }),
388
- getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
389
- setActiveToolExecutions: bridgeRuntime.lifecycle.setActiveToolExecutions,
390
- triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
391
- }),
392
404
  onMessageStart: previewRuntime.onMessageStart,
393
405
  onMessageUpdate: previewRuntime.onMessageUpdate,
394
406
  });
package/lib/config.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * Owns persisted bot/session pairing state, local config storage, authorization policy, and first-user pairing side effects
4
4
  */
5
5
 
6
+ import { existsSync } from "node:fs";
6
7
  import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
7
8
  import { homedir } from "node:os";
8
9
  import { join, resolve } from "node:path";
@@ -64,12 +65,9 @@ export interface TelegramConfigStoreOptions {
64
65
  export async function readTelegramConfig(
65
66
  configPath: string,
66
67
  ): Promise<TelegramConfig> {
67
- try {
68
- const content = await readFile(configPath, "utf8");
69
- return JSON.parse(content) as TelegramConfig;
70
- } catch {
71
- return {};
72
- }
68
+ if (!existsSync(configPath)) return {};
69
+ const content = await readFile(configPath, "utf8");
70
+ return JSON.parse(content) as TelegramConfig;
73
71
  }
74
72
 
75
73
  export async function writeTelegramConfig(
@@ -106,6 +106,16 @@ interface TelegramTopLevelFenceState {
106
106
  length: number;
107
107
  }
108
108
 
109
+ function isTelegramActionCommentContent(content: string): boolean {
110
+ const normalizedContent = content.replace(/^\s+/, "");
111
+ const [head = ""] = normalizedContent.split(/\r?\n/, 1);
112
+ return ["telegram_voice", "telegram_button"].some((command) => {
113
+ if (!head.startsWith(command)) return false;
114
+ const nextChar = head[command.length];
115
+ return nextChar === undefined || /\s|:/.test(nextChar);
116
+ });
117
+ }
118
+
109
119
  function getMarkdownLineEnd(markdown: string, offset: number): number {
110
120
  const newlineIndex = markdown.indexOf("\n", offset);
111
121
  return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
@@ -144,6 +154,28 @@ function isTopLevelClosingFence(
144
154
  );
145
155
  }
146
156
 
157
+ function collectInlineClosedTelegramActionBody(
158
+ markdown: string,
159
+ bodyStart: number,
160
+ commentContent: string,
161
+ ): { content: string; end: number } | undefined {
162
+ const bodyLineEnd = getMarkdownLineEnd(markdown, bodyStart);
163
+ const bodyLine = getMarkdownLineText(markdown, bodyStart, bodyLineEnd);
164
+ const closeLineEnd = getMarkdownLineEnd(markdown, bodyLineEnd);
165
+ const closeLine = getMarkdownLineText(markdown, bodyLineEnd, closeLineEnd);
166
+ const hasRecoverableBody =
167
+ isTelegramActionCommentContent(commentContent) &&
168
+ bodyLine.trim() !== "" &&
169
+ !bodyLine.startsWith("<!--") &&
170
+ !bodyLine.startsWith("-->") &&
171
+ closeLine === "-->";
172
+ if (!hasRecoverableBody) return undefined;
173
+ return {
174
+ content: `${commentContent.trimEnd()}\n${bodyLine}`,
175
+ end: bodyLineEnd + 3,
176
+ };
177
+ }
178
+
147
179
  function collectTopLevelHtmlComments(markdown: string): {
148
180
  comments: TelegramTopLevelHtmlComment[];
149
181
  openCommentStart?: number;
@@ -168,9 +200,23 @@ function collectTopLevelHtmlComments(markdown: string): {
168
200
  if (line.startsWith("<!--")) {
169
201
  const closeIndex = markdown.indexOf("-->", offset + 4);
170
202
  if (closeIndex === -1) return { comments, openCommentStart: offset };
171
- const end = closeIndex + 3;
172
- const raw = markdown.slice(offset, end);
173
- comments.push({ raw, content: raw.slice(4, -3), start: offset, end });
203
+ let end = closeIndex + 3;
204
+ let raw = markdown.slice(offset, end);
205
+ let content = raw.slice(4, -3);
206
+ const closeColumn = closeIndex - offset;
207
+ const closesOnOpeningLine = closeIndex < lineEnd;
208
+ const hasOnlyWhitespaceAfterClose =
209
+ line.slice(closeColumn + 3).trim() === "";
210
+ const inlineBody =
211
+ closesOnOpeningLine && hasOnlyWhitespaceAfterClose
212
+ ? collectInlineClosedTelegramActionBody(markdown, lineEnd, content)
213
+ : undefined;
214
+ if (inlineBody) {
215
+ end = inlineBody.end;
216
+ raw = markdown.slice(offset, end);
217
+ content = inlineBody.content;
218
+ }
219
+ comments.push({ raw, content, start: offset, end });
174
220
  offset = getMarkdownLineEnd(markdown, end);
175
221
  continue;
176
222
  }
@@ -239,18 +285,29 @@ function parseTopLevelTelegramComment(
239
285
  };
240
286
  }
241
287
 
288
+ function parseTelegramCommentAttributes(input: string): Record<string, string> {
289
+ const attributes: Record<string, string> = {};
290
+ for (const match of input.matchAll(
291
+ /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
292
+ )) {
293
+ const key = match[1];
294
+ const value = (match[2] ?? match[3] ?? match[4] ?? "").trim();
295
+ if (value) attributes[key] = value;
296
+ }
297
+ return attributes;
298
+ }
299
+
242
300
  function parseVoiceReplyAttributes(input: string): {
243
301
  lang?: string;
244
302
  rate?: string;
303
+ text?: string;
245
304
  } {
246
- const attributes: { lang?: string; rate?: string } = {};
247
- for (const token of input.trim().split(/\s+/).filter(Boolean)) {
248
- const [rawKey, ...valueParts] = token.split("=");
249
- const value = valueParts.join("=").trim();
250
- if (rawKey === "lang" && value) attributes.lang = value;
251
- if (rawKey === "rate" && value) attributes.rate = value;
252
- }
253
- return attributes;
305
+ const attributes = parseTelegramCommentAttributes(input);
306
+ return {
307
+ ...(attributes.lang ? { lang: attributes.lang } : {}),
308
+ ...(attributes.rate ? { rate: attributes.rate } : {}),
309
+ ...(attributes.text ? { text: attributes.text } : {}),
310
+ };
254
311
  }
255
312
 
256
313
  function parseVoiceCommentBody(
@@ -267,7 +324,8 @@ function parseVoiceCommentBody(
267
324
  if (trimmedHead.startsWith(":")) {
268
325
  return { attrs: "", text: trimmedHead.slice(1).trim() };
269
326
  }
270
- return { attrs: trimmedHead, text: "" };
327
+ const attrs = parseVoiceReplyAttributes(trimmedHead);
328
+ return { attrs: trimmedHead, text: attrs.text ?? "" };
271
329
  }
272
330
 
273
331
  function normalizeMarkdownAfterVoiceExtraction(markdown: string): string {
@@ -712,26 +770,36 @@ function normalizeMarkdownAfterButtonExtraction(markdown: string): string {
712
770
  return markdown.replace(/\n{3,}/g, "\n\n").trim();
713
771
  }
714
772
 
715
- function parseButtonsCommentAttributes(input: string): { label?: string } {
716
- const attributes: { label?: string } = {};
717
- for (const match of input.matchAll(
718
- /([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|'([^']*)'|(\S+))/g,
719
- )) {
720
- const key = match[1];
721
- const value = match[2] ?? match[3] ?? match[4] ?? "";
722
- if (key === "label" && value.trim()) attributes.label = value.trim();
723
- }
724
- return attributes;
773
+ function parseButtonsCommentAttributes(input: string): {
774
+ label?: string;
775
+ prompt?: string;
776
+ } {
777
+ const attributes = parseTelegramCommentAttributes(input);
778
+ return {
779
+ ...(attributes.label ? { label: attributes.label } : {}),
780
+ ...(attributes.prompt ? { prompt: attributes.prompt } : {}),
781
+ };
725
782
  }
726
783
 
727
784
  function parseButtonsCommentRows(
728
785
  head: string,
729
786
  body: string | undefined,
730
787
  ): TelegramOutboundButtonAction[][] {
731
- const attributes = parseButtonsCommentAttributes(head);
732
- if (!attributes.label) return [];
733
- const prompt = body?.trim() || attributes.label;
734
- return [[{ text: attributes.label, prompt }]];
788
+ const trimmedHead = head.trim();
789
+ if (body === undefined) {
790
+ if (trimmedHead.startsWith(":")) {
791
+ const label = trimmedHead.slice(1).trim();
792
+ return label ? [[{ text: label, prompt: label }]] : [];
793
+ }
794
+ const attributes = parseButtonsCommentAttributes(head);
795
+ return attributes.label && attributes.prompt
796
+ ? [[{ text: attributes.label, prompt: attributes.prompt }]]
797
+ : [];
798
+ }
799
+ const label = parseButtonsCommentAttributes(head).label;
800
+ const prompt = body.trim();
801
+ if (!label || !prompt) return [];
802
+ return [[{ text: label, prompt }]];
735
803
  }
736
804
 
737
805
  export function createTelegramButtonActionStore(
package/lib/prompts.ts CHANGED
@@ -9,15 +9,22 @@ import { TELEGRAM_PREFIX } from "./turns.ts";
9
9
  const SYSTEM_PROMPT_SUFFIX = `
10
10
 
11
11
  Telegram bridge extension is active.
12
- - Messages forwarded from Telegram are prefixed with "[telegram]".
13
- - [telegram] messages may include [attachments] sections with a base directory plus relative local file entries. Resolve and read those files as needed.
14
- - [telegram] messages may include a [reply] block after the user's current text. Treat [reply] as quoted context from the Telegram message the user replied to, not as a new instruction by itself; use it to resolve references like "this", "it", or "that message". The actual new user instruction is the message text before [reply], unless it explicitly asks you to act on the quoted context.
15
- - Telegram is often read on narrow phone screens, so prefer narrow table columns when presenting tabular data; wide monospace tables can become unreadable.
16
- - If a [telegram] user asked for a file or generated artifact, use telegram_attach with the local path instead of only mentioning the path in text.
17
- - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.
18
- - For Telegram-native outbound actions, use hidden top-level Markdown comments instead of agent-side tool calls: write a normal answer plus correctly formatted column-zero \`telegram_voice\` or \`telegram_button\` blocks outside code, quotes, and lists. The bridge handles delivery after \`agent_end\`, so do not call or register transport/TTS/text-to-OGG tools for these actions.
19
- - A \`telegram_voice\` block body is the text to synthesize through the extension's configured outbound-handler pipeline. It may be a short companion summary when useful, but no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
20
- - Button blocks should contain quick reply prompts the user can tap; use independent blocks like \`<!-- telegram_button label="OK"\nPrompt text\n-->\`, or \`<!-- telegram_button label="OK" -->\` when the prompt should equal the label. The callback prompt is routed back as a normal Telegram turn.`;
12
+
13
+ Inbound context:
14
+ - \`[telegram]\` marks Telegram-originated messages.
15
+ - \`[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.
16
+ - \`[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.
17
+
18
+ Telegram-visible output:
19
+ - Telegram is often phone-width; prefer narrow table columns because wide monospace tables can become unreadable.
20
+ - For requested/generated files, call tool \`telegram_attach(local_path)\`; mentioning a local path in text does not send it.
21
+
22
+ Native outbound actions:
23
+ - Use top-level column-zero hidden Markdown comments outside code, quotes, and lists; the bridge handles them after agent_end, so do not call or register transport/TTS/text-to-OGG tools.
24
+ - \`telegram_voice\`: text is synthesized through the configured outbound-handler pipeline. Use body text for multiline voice, \`<!-- telegram_voice text="Short summary" -->\` for explicit one-line voice, or \`<!-- telegram_voice: Short summary -->\` for one-line voice with no attributes. A companion summary is optional, no specific summary format is required. Keep it TTS-friendly; avoid raw Markdown, code, formulas, tables, or long lists.
25
+ - \`telegram_button\`: callback prompt is routed back as a normal Telegram turn. Use \`<!-- telegram_button: OK -->\` when prompt equals label, \`<!-- telegram_button label=Continue prompt="Continue with the current plan." -->\` for one-line prompts, or body form \`<!-- telegram_button label="Show risks"\nList the main risks first.\n-->\` for multiline prompts.
26
+ - If only hidden action comments would remain, add visible parent text like "Choose one:".
27
+ `;
21
28
 
22
29
  export function buildTelegramBridgeSystemPrompt(options: {
23
30
  prompt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for pi",
6
6
  "type": "module",