@llblab/pi-telegram 0.6.0 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,7 +171,7 @@ A TTS plus MP3-to-OGG setup can be expressed as `template: [...]`. The bridge pr
171
171
 
172
172
  #### Buttons
173
173
 
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:
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:
175
175
 
176
176
  ```md
177
177
  I can continue.
@@ -179,6 +179,8 @@ I can continue.
179
179
  <!-- telegram_button label="Continue"
180
180
  Continue with the current plan.
181
181
  -->
182
+
183
+ <!-- telegram_button label="OK" -->
182
184
  ```
183
185
 
184
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).
@@ -112,7 +112,7 @@ Dispatch is gated by:
112
112
  - `ctx.isIdle()` being true
113
113
  - `ctx.hasPendingMessages()` being false
114
114
 
115
- This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Telegram `/status` and `/model` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
115
+ This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/status` and `/model` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
116
116
 
117
117
  ### Abort Behavior
118
118
 
@@ -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. 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 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 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.
159
159
 
160
160
  ## Interactive Controls
161
161
 
@@ -39,7 +39,7 @@ Attachment handlers support these built-in placeholders:
39
39
 
40
40
  `defaults` may provide additional placeholder values such as `{lang}` or `{model}`. `args` is only a string-array declaration of supported placeholders; defaults belong in `defaults` or inline placeholders such as `{lang=ru}`. Examples prefer explicit flag-style CLIs for readability, but positional forms such as `/path/to/stt {file} {lang=ru} {model=voxtral-mini-latest}` are equally valid when the target script supports them.
41
41
 
42
- If a top-level one-step handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg for backwards compatibility. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
42
+ If a top-level one-step handler template has no `{file}` placeholder, the downloaded file path is appended as the last command arg as a one-step handler convenience. Composition steps are plain command templates and do not receive implicit file-path args; include `{file}` explicitly where needed.
43
43
 
44
44
  ## Ordered Fallbacks
45
45
 
@@ -75,7 +75,7 @@ For one-step `template` handlers, stdout remains the default result channel: the
75
75
 
76
76
  ## Buttons Markup
77
77
 
78
- Assistant replies can include independent button blocks. The block body is the prompt sent back to pi when the user taps the button:
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:
79
79
 
80
80
  ```md
81
81
  I can continue.
@@ -87,11 +87,13 @@ Continue with the current plan.
87
87
  <!-- telegram_button label="Show risks"
88
88
  List the main risks first.
89
89
  -->
90
+
91
+ <!-- telegram_button label="Done" -->
90
92
  ```
91
93
 
92
94
  Rules:
93
95
 
94
- - `telegram_button label="Label"` creates one independent button row whose prompt is the block body.
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.
95
97
  - 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.
96
98
  - Use one block per button; this mirrors HTML's singular element model and avoids a nested button DSL inside comments.
97
99
  - Button actions are stored in memory with short `callback_data`; Telegram never sees the full prompt in the button payload.
package/index.ts CHANGED
@@ -46,10 +46,16 @@ export default function (pi: Pi.ExtensionAPI) {
46
46
  const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
47
47
  getBotToken: configStore.getBotToken,
48
48
  });
49
- const mediaGroupRuntime =
50
- Media.createTelegramMediaGroupController<Api.TelegramMessage>();
49
+ const mediaGroupRuntime = Media.createTelegramMediaGroupController<
50
+ Api.TelegramMessage,
51
+ Pi.ExtensionContext
52
+ >();
51
53
  const telegramQueueStore =
52
54
  Queue.createTelegramQueueStore<Pi.ExtensionContext>();
55
+ const deferredQueueDispatchRuntime =
56
+ Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
57
+ recordRuntimeEvent: runtimeEvents.record,
58
+ });
53
59
  const pollingControllerState = Polling.createTelegramPollingControllerState();
54
60
  const { getStatusLines, updateStatus } =
55
61
  Status.createTelegramBridgeStatusRuntime<
@@ -88,12 +94,14 @@ export default function (pi: Pi.ExtensionAPI) {
88
94
  updateStatus,
89
95
  });
90
96
  const attachmentHandlerRuntime =
91
- AttachmentHandlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>({
92
- getHandlers: configStore.getAttachmentHandlers,
93
- execCommand: CommandTemplates.execCommandTemplate,
94
- getCwd: Pi.getExtensionContextCwd,
95
- recordRuntimeEvent: runtimeEvents.record,
96
- });
97
+ AttachmentHandlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>(
98
+ {
99
+ getHandlers: configStore.getAttachmentHandlers,
100
+ execCommand: CommandTemplates.execCommandTemplate,
101
+ getCwd: Pi.getExtensionContextCwd,
102
+ recordRuntimeEvent: runtimeEvents.record,
103
+ },
104
+ );
97
105
 
98
106
  // --- Telegram API ---
99
107
 
@@ -149,6 +157,7 @@ export default function (pi: Pi.ExtensionAPI) {
149
157
  hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
150
158
  isIdle: Pi.isExtensionContextIdle,
151
159
  hasPendingMessages: Pi.hasExtensionContextPendingMessages,
160
+ hasDispatchContext: deferredQueueDispatchRuntime.isBound,
152
161
  updateStatus,
153
162
  sendTextReply,
154
163
  recordRuntimeEvent: runtimeEvents.record,
@@ -274,8 +283,10 @@ export default function (pi: Pi.ExtensionAPI) {
274
283
  setPendingModelSwitch: pendingModelSwitchStore.set,
275
284
  syncCounters: bridgeRuntime.queue.syncCounters,
276
285
  syncFlags: bridgeRuntime.lifecycle.syncFlags,
286
+ bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind,
277
287
  prepareTempDir,
278
288
  updateStatus,
289
+ unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
279
290
  clearPendingMediaGroups: mediaGroupRuntime.clear,
280
291
  clearModelMenuState: modelMenuRuntime.clear,
281
292
  getActiveTurnChatId: activeTurnRuntime.getChatId,
@@ -352,6 +363,8 @@ export default function (pi: Pi.ExtensionAPI) {
352
363
  clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
353
364
  }),
354
365
  dispatchNextQueuedTelegramTurn,
366
+ requestDeferredDispatchNextQueuedTelegramTurn:
367
+ deferredQueueDispatchRuntime.request,
355
368
  clearPreview: previewRuntime.clear,
356
369
  setPreviewPendingText: previewRuntime.setPendingText,
357
370
  finalizeMarkdownPreview: previewRuntime.finalizeMarkdown,
@@ -362,16 +375,16 @@ export default function (pi: Pi.ExtensionAPI) {
362
375
  sendTextReply,
363
376
  recordRuntimeEvent: runtimeEvents.record,
364
377
  }),
365
- planOutboundReply: OutboundHandlers.createTelegramOutboundReplyPlanner(
366
- buttonActionStore,
367
- ),
368
- sendOutboundReplyArtifacts: OutboundHandlers.createTelegramOutboundReplyArtifactSender({
369
- execCommand: CommandTemplates.execCommandTemplate,
370
- sendMultipart: callMultipart,
371
- sendTextReply,
372
- getHandlers: configStore.getOutboundHandlers,
373
- recordRuntimeEvent: runtimeEvents.record,
374
- }),
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
+ }),
375
388
  getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
376
389
  setActiveToolExecutions: bridgeRuntime.lifecycle.setActiveToolExecutions,
377
390
  triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
@@ -33,6 +33,7 @@ export interface CommandTemplateExecOptions {
33
33
  timeout?: number;
34
34
  signal?: AbortSignal;
35
35
  stdin?: string;
36
+ killGrace?: number;
36
37
  }
37
38
 
38
39
  export interface CommandTemplateExecResult {
@@ -207,18 +208,20 @@ export function execCommandTemplate(
207
208
  let killed = false;
208
209
  let settled = false;
209
210
  let timeoutId: NodeJS.Timeout | undefined;
211
+ let killTimeoutId: NodeJS.Timeout | undefined;
210
212
  const killProcess = (): void => {
211
213
  if (killed) return;
212
214
  killed = true;
213
215
  proc.kill("SIGTERM");
214
- setTimeout(() => {
215
- if (!proc.killed) proc.kill("SIGKILL");
216
- }, 5000);
216
+ killTimeoutId = setTimeout(() => {
217
+ if (!settled) proc.kill("SIGKILL");
218
+ }, options.killGrace ?? 5000);
217
219
  };
218
220
  const settle = (code: number): void => {
219
221
  if (settled) return;
220
222
  settled = true;
221
223
  if (timeoutId) clearTimeout(timeoutId);
224
+ if (killTimeoutId) clearTimeout(killTimeoutId);
222
225
  if (options.signal)
223
226
  options.signal.removeEventListener("abort", killProcess);
224
227
  resolve({ stdout, stderr, code, killed });
package/lib/locks.ts CHANGED
@@ -246,13 +246,6 @@ export function createTelegramLockedPollingRuntime<
246
246
  clearInterval(ownershipInterval);
247
247
  ownershipInterval = undefined;
248
248
  };
249
- const updateStatusSafely = (ctx: TContext, phase: string) => {
250
- try {
251
- deps.updateStatus(ctx);
252
- } catch (error) {
253
- deps.recordRuntimeEvent?.("lock", error, { phase });
254
- }
255
- };
256
249
  const suspendPolling = async () => {
257
250
  stopOwnershipWatcher();
258
251
  if (ownershipStop) {
@@ -261,7 +254,7 @@ export function createTelegramLockedPollingRuntime<
261
254
  }
262
255
  await deps.stopPolling();
263
256
  };
264
- const stopAfterOwnershipLoss = (ctx: TContext) => {
257
+ const stopAfterOwnershipLoss = () => {
265
258
  if (ownershipStop) return;
266
259
  stopOwnershipWatcher();
267
260
  ownershipStop = deps
@@ -271,7 +264,6 @@ export function createTelegramLockedPollingRuntime<
271
264
  )
272
265
  .finally(() => {
273
266
  ownershipStop = undefined;
274
- updateStatusSafely(ctx, "ownership-loss-status");
275
267
  });
276
268
  };
277
269
  const startOwnershipWatcher = (ctx: TContext) => {
@@ -279,7 +271,7 @@ export function createTelegramLockedPollingRuntime<
279
271
  stopOwnershipWatcher();
280
272
  ownershipInterval = setInterval(() => {
281
273
  if (deps.lock.owns(owner)) return;
282
- stopAfterOwnershipLoss(ctx);
274
+ stopAfterOwnershipLoss();
283
275
  }, ownershipCheckMs);
284
276
  ownershipInterval.unref?.();
285
277
  };
package/lib/media.ts CHANGED
@@ -59,17 +59,20 @@ export interface TelegramMediaGroupMessage {
59
59
  media_group_id?: string;
60
60
  }
61
61
 
62
- export interface TelegramMediaGroupState<TMessage> {
62
+ export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
63
63
  messages: TMessage[];
64
+ context?: TContext;
64
65
  flushTimer?: ReturnType<typeof setTimeout>;
65
66
  }
66
67
 
67
68
  export interface TelegramMediaGroupController<
68
69
  TMessage extends TelegramMediaGroupMessage,
70
+ TContext = unknown,
69
71
  > {
70
72
  queueMessage: (options: {
71
73
  message: TMessage;
72
- dispatchMessages: (messages: TMessage[]) => void;
74
+ context?: TContext;
75
+ dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
73
76
  }) => boolean;
74
77
  removeMessages: (messageIds: number[]) => number;
75
78
  clear: () => void;
@@ -79,7 +82,7 @@ export interface TelegramMediaGroupDispatchRuntimeDeps<
79
82
  TMessage extends TelegramMediaGroupMessage,
80
83
  TContext,
81
84
  > {
82
- mediaGroups: TelegramMediaGroupController<TMessage>;
85
+ mediaGroups: TelegramMediaGroupController<TMessage, TContext>;
83
86
  dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
84
87
  }
85
88
 
@@ -249,7 +252,7 @@ export function getTelegramMediaGroupKey(
249
252
  export function removePendingTelegramMediaGroupMessages<
250
253
  TMessage extends TelegramMediaGroupMessage,
251
254
  >(
252
- groups: Map<string, TelegramMediaGroupState<TMessage>>,
255
+ groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>,
253
256
  messageIds: number[],
254
257
  clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
255
258
  ): number {
@@ -273,24 +276,27 @@ export function removePendingTelegramMediaGroupMessages<
273
276
 
274
277
  export function queueTelegramMediaGroupMessage<
275
278
  TMessage extends TelegramMediaGroupMessage,
279
+ TContext = unknown,
276
280
  >(options: {
277
281
  message: TMessage;
278
- groups: Map<string, TelegramMediaGroupState<TMessage>>;
282
+ context?: TContext;
283
+ groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
279
284
  debounceMs: number;
280
285
  setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
281
286
  clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
282
- dispatchMessages: (messages: TMessage[]) => void;
287
+ dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
283
288
  }): boolean {
284
289
  const key = getTelegramMediaGroupKey(options.message);
285
290
  if (!key) return false;
286
291
  const existing = options.groups.get(key) ?? { messages: [] };
287
292
  existing.messages.push(options.message);
293
+ existing.context = options.context;
288
294
  if (existing.flushTimer) options.clearTimer(existing.flushTimer);
289
295
  existing.flushTimer = options.setTimer(() => {
290
296
  const state = options.groups.get(key);
291
297
  options.groups.delete(key);
292
298
  if (!state) return;
293
- options.dispatchMessages(state.messages);
299
+ options.dispatchMessages(state.messages, state.context);
294
300
  }, options.debounceMs);
295
301
  options.groups.set(key, existing);
296
302
  return true;
@@ -298,10 +304,11 @@ export function queueTelegramMediaGroupMessage<
298
304
 
299
305
  export function createTelegramMediaGroupController<
300
306
  TMessage extends TelegramMediaGroupMessage,
307
+ TContext = unknown,
301
308
  >(
302
309
  options: TelegramMediaGroupControllerOptions = {},
303
- ): TelegramMediaGroupController<TMessage> {
304
- const groups = new Map<string, TelegramMediaGroupState<TMessage>>();
310
+ ): TelegramMediaGroupController<TMessage, TContext> {
311
+ const groups = new Map<string, TelegramMediaGroupState<TMessage, TContext>>();
305
312
  const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
306
313
  const setTimer =
307
314
  options.setTimer ??
@@ -309,9 +316,10 @@ export function createTelegramMediaGroupController<
309
316
  setTimeout(callback, ms));
310
317
  const clearTimer = options.clearTimer ?? clearTimeout;
311
318
  return {
312
- queueMessage: ({ message, dispatchMessages }) =>
319
+ queueMessage: ({ message, context, dispatchMessages }) =>
313
320
  queueTelegramMediaGroupMessage({
314
321
  message,
322
+ context,
315
323
  groups,
316
324
  debounceMs,
317
325
  setTimer,
@@ -339,8 +347,11 @@ export function createTelegramMediaGroupDispatchRuntime<
339
347
  handleMessage: async (message, ctx) => {
340
348
  const queuedMediaGroup = deps.mediaGroups.queueMessage({
341
349
  message,
342
- dispatchMessages: (messages) => {
343
- void deps.dispatchMessages(messages, ctx);
350
+ context: ctx,
351
+ dispatchMessages: (messages, queuedCtx) => {
352
+ if (queuedCtx !== undefined) {
353
+ void deps.dispatchMessages(messages, queuedCtx);
354
+ }
344
355
  },
345
356
  });
346
357
  if (queuedMediaGroup) return;
@@ -101,6 +101,11 @@ interface TelegramTopLevelHtmlComment {
101
101
  end: number;
102
102
  }
103
103
 
104
+ interface TelegramTopLevelFenceState {
105
+ marker: "`" | "~";
106
+ length: number;
107
+ }
108
+
104
109
  function getMarkdownLineEnd(markdown: string, offset: number): number {
105
110
  const newlineIndex = markdown.indexOf("\n", offset);
106
111
  return newlineIndex === -1 ? markdown.length : newlineIndex + 1;
@@ -114,9 +119,29 @@ function getMarkdownLineText(
114
119
  return markdown.slice(offset, end).replace(/\r?\n$/, "");
115
120
  }
116
121
 
117
- function getTopLevelFenceMarker(line: string): "```" | "~~~" | undefined {
118
- const match = line.match(/^(?: {0,3})(```|~~~)/);
119
- return match?.[1] as "```" | "~~~" | undefined;
122
+ function getTopLevelOpeningFence(
123
+ line: string,
124
+ ): TelegramTopLevelFenceState | undefined {
125
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})/);
126
+ const sequence = match?.[1];
127
+ if (!sequence) return undefined;
128
+ return {
129
+ marker: sequence[0] as "`" | "~",
130
+ length: sequence.length,
131
+ };
132
+ }
133
+
134
+ function isTopLevelClosingFence(
135
+ line: string,
136
+ fence: TelegramTopLevelFenceState,
137
+ ): boolean {
138
+ const match = line.match(/^(?: {0,3})(`{3,}|~{3,})([ \t]*)$/);
139
+ const sequence = match?.[1];
140
+ return (
141
+ !!sequence &&
142
+ sequence[0] === fence.marker &&
143
+ sequence.length >= fence.length
144
+ );
120
145
  }
121
146
 
122
147
  function collectTopLevelHtmlComments(markdown: string): {
@@ -125,18 +150,18 @@ function collectTopLevelHtmlComments(markdown: string): {
125
150
  } {
126
151
  const comments: TelegramTopLevelHtmlComment[] = [];
127
152
  let offset = 0;
128
- let fenceMarker: "```" | "~~~" | undefined;
153
+ let fence: TelegramTopLevelFenceState | undefined;
129
154
  while (offset < markdown.length) {
130
155
  const lineEnd = getMarkdownLineEnd(markdown, offset);
131
156
  const line = getMarkdownLineText(markdown, offset, lineEnd);
132
- if (fenceMarker) {
133
- if (line.startsWith(fenceMarker)) fenceMarker = undefined;
157
+ if (fence) {
158
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
134
159
  offset = lineEnd;
135
160
  continue;
136
161
  }
137
- const nextFenceMarker = getTopLevelFenceMarker(line);
138
- if (nextFenceMarker) {
139
- fenceMarker = nextFenceMarker;
162
+ const nextFence = getTopLevelOpeningFence(line);
163
+ if (nextFence) {
164
+ fence = nextFence;
140
165
  offset = lineEnd;
141
166
  continue;
142
167
  }
@@ -174,19 +199,19 @@ function findTopLevelOpenOrPartialHtmlCommentIndex(markdown: string): number {
174
199
  const { openCommentStart } = collectTopLevelHtmlComments(markdown);
175
200
  if (openCommentStart !== undefined) return openCommentStart;
176
201
  let offset = 0;
177
- let fenceMarker: "```" | "~~~" | undefined;
202
+ let fence: TelegramTopLevelFenceState | undefined;
178
203
  while (offset < markdown.length) {
179
204
  const lineEnd = getMarkdownLineEnd(markdown, offset);
180
205
  const line = getMarkdownLineText(markdown, offset, lineEnd);
181
206
  const isLastLine = lineEnd >= markdown.length;
182
- if (fenceMarker) {
183
- if (line.startsWith(fenceMarker)) fenceMarker = undefined;
207
+ if (fence) {
208
+ if (isTopLevelClosingFence(line, fence)) fence = undefined;
184
209
  offset = lineEnd;
185
210
  continue;
186
211
  }
187
- const nextFenceMarker = getTopLevelFenceMarker(line);
188
- if (nextFenceMarker) {
189
- fenceMarker = nextFenceMarker;
212
+ const nextFence = getTopLevelOpeningFence(line);
213
+ if (nextFence) {
214
+ fence = nextFence;
190
215
  offset = lineEnd;
191
216
  continue;
192
217
  }
@@ -251,9 +276,8 @@ function normalizeMarkdownAfterVoiceExtraction(markdown: string): string {
251
276
 
252
277
  export function stripTelegramCommentMarkupForPreview(markdown: string): string {
253
278
  const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
254
- const openBlockIndex = findTopLevelOpenOrPartialHtmlCommentIndex(
255
- withoutClosedBlocks,
256
- );
279
+ const openBlockIndex =
280
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
257
281
  const previewMarkdown =
258
282
  openBlockIndex >= 0
259
283
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -265,9 +289,8 @@ export function stripTelegramCommentMarkupForDelivery(
265
289
  markdown: string,
266
290
  ): string {
267
291
  const withoutClosedBlocks = replaceTopLevelHtmlComments(markdown, () => "");
268
- const openBlockIndex = findTopLevelOpenOrPartialHtmlCommentIndex(
269
- withoutClosedBlocks,
270
- );
292
+ const openBlockIndex =
293
+ findTopLevelOpenOrPartialHtmlCommentIndex(withoutClosedBlocks);
271
294
  const deliveryMarkdown =
272
295
  openBlockIndex >= 0
273
296
  ? withoutClosedBlocks.slice(0, openBlockIndex)
@@ -343,7 +366,9 @@ function getVoiceReplyCompositionStepTimeout(
343
366
  ): number {
344
367
  const remaining = getRemainingVoiceReplyTimeout(handlerTimeout, startedAt);
345
368
  const stepTimeout = getVoiceReplyConfiguredTimeout(step);
346
- return stepTimeout === undefined ? remaining : Math.min(stepTimeout, remaining);
369
+ return stepTimeout === undefined
370
+ ? remaining
371
+ : Math.min(stepTimeout, remaining);
347
372
  }
348
373
 
349
374
  function formatVoiceReplyExecutionFailure(
@@ -704,8 +729,8 @@ function parseButtonsCommentRows(
704
729
  body: string | undefined,
705
730
  ): TelegramOutboundButtonAction[][] {
706
731
  const attributes = parseButtonsCommentAttributes(head);
707
- const prompt = body?.trim();
708
- if (!attributes.label || !prompt) return [];
732
+ if (!attributes.label) return [];
733
+ const prompt = body?.trim() || attributes.label;
709
734
  return [[{ text: attributes.label, prompt }]];
710
735
  }
711
736
 
package/lib/polling.ts CHANGED
@@ -97,7 +97,7 @@ export interface TelegramPollingRuntimeDeps<TContext> {
97
97
  setPollingController: (controller: AbortController | undefined) => void;
98
98
  stopTypingLoop: () => unknown;
99
99
  runPollLoop: (ctx: TContext, signal: AbortSignal) => Promise<void>;
100
- updateStatus: (ctx: TContext) => void;
100
+ updateStatus: (ctx: TContext, message?: string) => void;
101
101
  createAbortController?: () => AbortController;
102
102
  }
103
103
 
package/lib/prompts.ts CHANGED
@@ -17,7 +17,7 @@ Telegram bridge extension is active.
17
17
  - Do not assume mentioning a local file path in plain text will send it to Telegram. Use telegram_attach.
18
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
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-->\`. The callback prompt is routed back as a normal Telegram turn.`;
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.`;
21
21
 
22
22
  export function buildTelegramBridgeSystemPrompt(options: {
23
23
  prompt: string;
package/lib/queue.ts CHANGED
@@ -785,6 +785,9 @@ export interface TelegramAgentEndHookRuntimeDeps<
785
785
  resetRuntimeState: () => void;
786
786
  updateStatus: (ctx: TContext) => void;
787
787
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
788
+ requestDeferredDispatchNextQueuedTelegramTurn: (
789
+ dispatch: (ctx: TContext) => void,
790
+ ) => void;
788
791
  clearPreview: (chatId: number) => Promise<void>;
789
792
  setPreviewPendingText: (text: string) => void;
790
793
  finalizeMarkdownPreview: TelegramAgentEndRuntimeDeps<TTurn>["finalizeMarkdownPreview"];
@@ -882,7 +885,9 @@ export function createTelegramAgentEndHook<
882
885
  resetRuntimeState: deps.resetRuntimeState,
883
886
  updateStatus: () => deps.updateStatus(ctx),
884
887
  dispatchNextQueuedTelegramTurn: () => {
885
- setTimeout(() => deps.dispatchNextQueuedTelegramTurn(ctx), 0);
888
+ deps.requestDeferredDispatchNextQueuedTelegramTurn(
889
+ deps.dispatchNextQueuedTelegramTurn,
890
+ );
886
891
  },
887
892
  clearPreview: deps.clearPreview,
888
893
  setPreviewPendingText: deps.setPreviewPendingText,
@@ -1020,15 +1025,18 @@ export interface TelegramSessionStateApplier<TQueueItem, TModel> {
1020
1025
  applyShutdownState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
1021
1026
  }
1022
1027
 
1023
- export interface TelegramSessionStartRuntimeDeps<TModel = unknown> {
1028
+ export interface TelegramSessionStartRuntimeDeps<TContext, TModel = unknown> {
1029
+ ctx: TContext;
1024
1030
  currentModel: TModel | undefined;
1025
1031
  loadConfig: () => Promise<void>;
1026
1032
  applyState: (state: TelegramSessionStartState<TModel>) => void;
1033
+ bindDeferredDispatchContext?: (ctx: TContext) => void;
1027
1034
  prepareTempDir: () => Promise<unknown>;
1028
1035
  updateStatus: () => void;
1029
1036
  }
1030
1037
 
1031
1038
  export interface TelegramSessionShutdownRuntimeDeps<TQueueItem> {
1039
+ unbindDeferredDispatchContext?: () => void;
1032
1040
  applyState: (state: TelegramSessionShutdownState<TQueueItem>) => void;
1033
1041
  clearPendingMediaGroups: () => void;
1034
1042
  clearModelMenuState: () => void;
@@ -1047,8 +1055,10 @@ export interface TelegramSessionLifecycleHookRuntimeDeps<
1047
1055
  getCurrentModel: (ctx: TContext) => TModel | undefined;
1048
1056
  loadConfig: () => Promise<void>;
1049
1057
  applySessionStartState: (state: TelegramSessionStartState<TModel>) => void;
1058
+ bindDeferredDispatchContext?: (ctx: TContext) => void;
1050
1059
  prepareTempDir: () => Promise<unknown>;
1051
1060
  updateStatus: (ctx: TContext) => void;
1061
+ unbindDeferredDispatchContext?: () => void;
1052
1062
  applySessionShutdownState: (
1053
1063
  state: TelegramSessionShutdownState<TQueueItem>,
1054
1064
  ) => void;
@@ -1185,18 +1195,20 @@ export function buildTelegramSessionShutdownState<
1185
1195
  };
1186
1196
  }
1187
1197
 
1188
- export async function startTelegramSessionRuntime<TModel = unknown>(
1189
- deps: TelegramSessionStartRuntimeDeps<TModel>,
1198
+ export async function startTelegramSessionRuntime<TContext, TModel = unknown>(
1199
+ deps: TelegramSessionStartRuntimeDeps<TContext, TModel>,
1190
1200
  ): Promise<void> {
1191
1201
  await deps.loadConfig();
1192
1202
  deps.applyState(buildTelegramSessionStartState(deps.currentModel));
1193
1203
  await deps.prepareTempDir();
1204
+ deps.bindDeferredDispatchContext?.(deps.ctx);
1194
1205
  deps.updateStatus();
1195
1206
  }
1196
1207
 
1197
1208
  export async function shutdownTelegramSessionRuntime<TQueueItem>(
1198
1209
  deps: TelegramSessionShutdownRuntimeDeps<TQueueItem>,
1199
1210
  ): Promise<void> {
1211
+ deps.unbindDeferredDispatchContext?.();
1200
1212
  deps.applyState(buildTelegramSessionShutdownState<TQueueItem>());
1201
1213
  deps.clearPendingMediaGroups();
1202
1214
  deps.clearModelMenuState();
@@ -1235,8 +1247,10 @@ export function createTelegramSessionLifecycleRuntime<
1235
1247
  getCurrentModel: deps.getCurrentModel,
1236
1248
  loadConfig: deps.loadConfig,
1237
1249
  applySessionStartState: stateApplier.applyStartState,
1250
+ bindDeferredDispatchContext: deps.bindDeferredDispatchContext,
1238
1251
  prepareTempDir: deps.prepareTempDir,
1239
1252
  updateStatus: deps.updateStatus,
1253
+ unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
1240
1254
  applySessionShutdownState: stateApplier.applyShutdownState,
1241
1255
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
1242
1256
  clearModelMenuState: deps.clearModelMenuState,
@@ -1261,9 +1275,11 @@ export function createTelegramSessionLifecycleHooks<
1261
1275
  ): Promise<void> => {
1262
1276
  try {
1263
1277
  await startTelegramSessionRuntime({
1278
+ ctx,
1264
1279
  currentModel: deps.getCurrentModel(ctx),
1265
1280
  loadConfig: deps.loadConfig,
1266
1281
  applyState: deps.applySessionStartState,
1282
+ bindDeferredDispatchContext: deps.bindDeferredDispatchContext,
1267
1283
  prepareTempDir: deps.prepareTempDir,
1268
1284
  updateStatus: () => deps.updateStatus(ctx),
1269
1285
  });
@@ -1275,6 +1291,7 @@ export function createTelegramSessionLifecycleHooks<
1275
1291
  onSessionShutdown: async (): Promise<void> => {
1276
1292
  try {
1277
1293
  await shutdownTelegramSessionRuntime<TQueueItem>({
1294
+ unbindDeferredDispatchContext: deps.unbindDeferredDispatchContext,
1278
1295
  applyState: deps.applySessionShutdownState,
1279
1296
  clearPendingMediaGroups: deps.clearPendingMediaGroups,
1280
1297
  clearModelMenuState: deps.clearModelMenuState,
@@ -1490,6 +1507,68 @@ export async function executeTelegramControlItemRuntime<TContext>(
1490
1507
  }
1491
1508
  }
1492
1509
 
1510
+ // --- Deferred Dispatch Runtime ---
1511
+
1512
+ export interface TelegramDeferredQueueDispatchRuntimeDeps extends TelegramRuntimeEventRecorderPort {
1513
+ delayMs?: number;
1514
+ setTimer?: (
1515
+ callback: () => void,
1516
+ ms: number,
1517
+ ) => ReturnType<typeof setTimeout>;
1518
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
1519
+ }
1520
+
1521
+ export interface TelegramDeferredQueueDispatchRuntime<TContext = unknown> {
1522
+ bind: (ctx: TContext) => void;
1523
+ unbind: () => void;
1524
+ isBound: () => boolean;
1525
+ request: (dispatchNextQueuedTelegramTurn: (ctx: TContext) => void) => void;
1526
+ }
1527
+
1528
+ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
1529
+ deps: TelegramDeferredQueueDispatchRuntimeDeps = {},
1530
+ ): TelegramDeferredQueueDispatchRuntime<TContext> {
1531
+ let boundContext: TContext | undefined;
1532
+ let generation = 0;
1533
+ const timers = new Set<ReturnType<typeof setTimeout>>();
1534
+ const delayMs = deps.delayMs ?? 0;
1535
+ const setTimer =
1536
+ deps.setTimer ??
1537
+ ((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
1538
+ setTimeout(callback, ms));
1539
+ const clearTimer =
1540
+ deps.clearTimer ??
1541
+ ((timer: ReturnType<typeof setTimeout>): void => clearTimeout(timer));
1542
+ const clearTimers = (): void => {
1543
+ for (const timer of timers) clearTimer(timer);
1544
+ timers.clear();
1545
+ };
1546
+ return {
1547
+ bind: (ctx) => {
1548
+ boundContext = ctx;
1549
+ generation += 1;
1550
+ },
1551
+ unbind: () => {
1552
+ boundContext = undefined;
1553
+ generation += 1;
1554
+ clearTimers();
1555
+ },
1556
+ isBound: () => boundContext !== undefined,
1557
+ request: (dispatchNextQueuedTelegramTurn) => {
1558
+ if (boundContext === undefined) return;
1559
+ const scheduledGeneration = generation;
1560
+ let timer: ReturnType<typeof setTimeout>;
1561
+ timer = setTimer(() => {
1562
+ timers.delete(timer);
1563
+ if (generation !== scheduledGeneration || boundContext === undefined)
1564
+ return;
1565
+ dispatchNextQueuedTelegramTurn(boundContext);
1566
+ }, delayMs);
1567
+ timers.add(timer);
1568
+ },
1569
+ };
1570
+ }
1571
+
1493
1572
  // --- Dispatch Runtime ---
1494
1573
 
1495
1574
  export interface TelegramDispatchRuntimeDeps<TContext = unknown> {
@@ -1516,6 +1595,7 @@ export interface TelegramQueueDispatchControllerDeps<
1516
1595
  getQueuedItems: () => TelegramQueueItem<TContext>[];
1517
1596
  setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
1518
1597
  canDispatch: (ctx: TContext) => boolean;
1598
+ hasDispatchContext?: () => boolean;
1519
1599
  updateStatus: (ctx: TContext, error?: string) => void;
1520
1600
  sendTextReply: TelegramControlRuntimeDeps<TContext>["sendTextReply"];
1521
1601
  onPromptDispatchStart: (ctx: TContext, chatId: number) => void;
@@ -1567,6 +1647,7 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
1567
1647
  isIdle: deps.isIdle,
1568
1648
  hasPendingMessages: deps.hasPendingMessages,
1569
1649
  }),
1650
+ hasDispatchContext: deps.hasDispatchContext,
1570
1651
  updateStatus: deps.updateStatus,
1571
1652
  sendTextReply: deps.sendTextReply,
1572
1653
  onPromptDispatchStart: deps.onPromptDispatchStart,
@@ -1582,6 +1663,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
1582
1663
  let controlDispatchPending = false;
1583
1664
  const controller: TelegramQueueDispatchController<TContext> = {
1584
1665
  dispatchNext: (ctx) => {
1666
+ if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
1585
1667
  if (controlDispatchPending) {
1586
1668
  deps.updateStatus(ctx);
1587
1669
  return;
@@ -1603,6 +1685,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
1603
1685
  recordRuntimeEvent: deps.recordRuntimeEvent,
1604
1686
  onSettled: () => {
1605
1687
  controlDispatchPending = false;
1688
+ if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
1606
1689
  deps.updateStatus(ctx);
1607
1690
  controller.dispatchNext(ctx);
1608
1691
  },
package/lib/routing.ts CHANGED
@@ -41,7 +41,7 @@ export interface TelegramInboundRouteRuntimeDeps<
41
41
  >;
42
42
  bridgeRuntime: TelegramBridgeRuntime;
43
43
  activeTurnRuntime: Queue.TelegramActiveTurnStore;
44
- mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage>;
44
+ mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
45
45
  telegramQueueStore: Queue.TelegramQueueStateStore<TContext>;
46
46
  queueMutationRuntime: Queue.TelegramQueueMutationController<TContext>;
47
47
  modelMenuRuntime: Menu.TelegramModelMenuRuntime<TModel>;
package/lib/runtime.ts CHANGED
@@ -353,7 +353,6 @@ export function createTelegramTypingLoopStarter<TContext>(
353
353
  deps.recordRuntimeEvent?.("typing", error, {
354
354
  chatId: targetChatId,
355
355
  });
356
- deps.updateStatus(ctx, `typing failed: ${message}`);
357
356
  }
358
357
  },
359
358
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for pi",
6
6
  "type": "module",