@llblab/pi-telegram 0.8.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2: Lock-Safe Delivery
4
+
5
+ - `[Lock Safety]` Active Telegram turns now re-check singleton ownership before preview flushes and final agent-end delivery. Impact: an old π instance stays silent after another instance takes the Telegram bridge lock, even if the old instance finishes a long-running prompt later.
6
+ - `[Inbound Handlers]` The first step of an inbound composition now receives the full configured handler timeout before elapsed-time accounting starts on later steps. Impact: composition timeout behavior is deterministic and avoids one-millisecond test/runtime drift at pipeline start.
7
+ - `[Menu UI]` Model and Thinking submenu headers now include their matching command icons (`🤖` and `🧠`). Impact: submenu headings match the Queue menu's icon-led style.
8
+ - `[Package]` Bumped package metadata to `0.8.2` and kept the lockfile in sync.
9
+
10
+ ## 0.8.1: Outbound Voice Translation Hotfix
11
+
12
+ - `[Outbound Voice]` Composed voice handlers now pass the original `telegram_voice` text to the first pipeline step through stdin, then continue piping each step's stdout into the next step. Impact: translate-from-stdin voice pipelines can translate hidden voice text before TTS instead of failing with an empty first-step input.
13
+ - `[Queue Menu]` Queue item detail previews now render prompt text inside a bounded raw `<pre>` block, and generic queue navigation/headings use the `⏳` waiting icon. Impact: absolute file paths and attachment references remain readable without Telegram interpreting slash-prefixed paths as commands, long previews are truncated below Telegram's message limit, and the queue surface has a clearer generic icon distinct from ordered-list or priority markers.
14
+ - `[Queue Delete]` Queue item removal now uses explicit `🗑 Delete` wording and opens a two-button confirmation (`🗑 Yes, delete` / `❌ No`) before mutating the queue. Impact: accidental queue-item deletion is harder while the item detail flow remains compact.
15
+ - `[Queue Priority]` Priority reactions now preserve the exact normalized promotion emoji and render it in both queue-menu rows and the π status-bar queued preview. Reaction metadata is grouped into semantic id ranges (`10..13` for priority, `20..23` for removal). Impact: `👍`, `⚡`, `❤️`, and `🕊️` keep the same priority semantics while making the user's chosen reaction visible across Telegram and TUI surfaces.
16
+ - `[Configuration Docs]` Documented the configuration philosophy that rich visual/TUI setup stays minimal for now while agents can read README/docs and update `telegram.json` for advanced workflows. Impact: configuration guidance matches the extension's agent-assisted operator model without adding premature TUI surfaces.
17
+ - `[Outbound Docs]` Tightened voice-handler critical-step wording around transform → TTS → conversion pipelines and handler-level fallbacks. Impact: docs now match translated voice pipelines without implying provider-specific TTS fallbacks.
18
+ - `[Package]` Bumped package metadata to `0.8.1` and kept the lockfile in sync.
19
+ - `[Command Template Docs]` Synchronized `docs/command-templates.md` bit-for-bit with the current portable standard shared by `pi-auto-tools`. Impact: the documented standard now includes retry, fail-open composition, critical-step abort semantics, and the 30s default timeout in the same wording across both extensions.
20
+ - `[Lock Docs]` Synchronized `docs/locks.md` bit-for-bit with the extension-neutral Locks Standard shared by `pi-wakeup`. Impact: singleton ownership documentation no longer carries project-specific examples that prevent exact reuse across extensions.
21
+
3
22
  ## 0.8.0: Handler Bus
4
23
 
5
24
  - `[Inbound Handlers]` Added `inboundHandlers` as the provider-neutral Telegram → π transformation bus. Raw Telegram text can match `type: "text"`, `mime: "text/plain"`, or `mime: "text/*"`, receives text on stdin and `{text}`, and non-empty stdout replaces the prompt text before queueing; media/file handlers keep the existing `{file}`/`{mime}`/`{type}` behavior with optional independent selectors. Impact: translation, normalization, STT, OCR, and file extraction can share one command-template integration model.
package/README.md CHANGED
@@ -37,6 +37,12 @@ pi install git:github.com/llblab/pi-telegram
37
37
 
38
38
  ## Configure
39
39
 
40
+ ### Configuration Philosophy
41
+
42
+ The extension intentionally keeps rich visual/TUI configuration minimal for now. Rich setup screens may arrive later, but they are not the main configuration surface yet.
43
+
44
+ For advanced setup, ask an agent to read this `README.md` and the docs, then update `~/.pi/agent/telegram.json` for your workflow. Agents are good at small configuration changes, and this keeps the bridge simple while handler pipelines and operator preferences continue to evolve.
45
+
40
46
  ### 1. Telegram Bot
41
47
 
42
48
  1. Open [@BotFather](https://t.me/BotFather)
@@ -4,7 +4,7 @@ Command templates are the portable integration format for deterministic local au
4
4
 
5
5
  **Meta-contract:** transportable (bit-for-bit identical across projects), high-density (zero fluff), constant (evolve by crystallizing, not speculating), optimal minimum (add only when it hurts).
6
6
 
7
- **Scope:** portable command execution format — shell-free exec, composition/pipes, default timeout, critical-step branching, output artifact selection, handler-level fallback. Single JSON standard; no platform lock-in.
7
+ **Scope:** portable command execution format — shell-free exec, composition/pipes, timeout (30s default), retry, critical-step branching, output artifact selection, handler-level fallback. Single JSON standard; no platform lock-in.
8
8
 
9
9
  ---
10
10
 
@@ -35,8 +35,9 @@ Common object fields:
35
35
  | `template` | Required command string or ordered composition array |
36
36
  | `args` | Optional placeholder-name declarations only; never stores defaults |
37
37
  | `defaults` | Placeholder default values by name |
38
- | `timeout` | Optional execution timeout override in milliseconds; default `30000` (30s) |
38
+ | `timeout` | Optional execution timeout in milliseconds; default `30000` (30s) |
39
39
  | `output` | Optional result selector; default `"stdout"`, or a "runtime value", e.g. `"ogg"` |
40
+ | `retry` | Optional max attempts (including first); default `1`. Retries immediately on non-zero exit |
40
41
  | `critical` | Optional boolean; default `false`. When `true`, failure aborts the entire root composition |
41
42
 
42
43
  Storage paths, labels, selectors, descriptions, and registry-specific metadata belong to each extension's local schema.
@@ -118,7 +119,7 @@ template="echo 'literal words' {text}"
118
119
 
119
120
  Composition rules:
120
121
 
121
- - Execute leaves in order and stop on the first non-zero exit
122
+ - Execute leaves in order; non-critical failures are recorded and execution continues, while `critical: true` failures abort the root composition
122
123
  - Treat the whole composition as one handler for selector matching and fallback
123
124
  - Top-level `args` and `defaults` apply to every leaf unless the leaf defines private values
124
125
  - Leaf `args` replace inherited `args`; leaf `defaults` merge over inherited defaults; `timeout` and `output` are not inherited into leaves
@@ -167,6 +168,21 @@ Set `critical: true` on any leaf to abort the entire root composition on failure
167
168
 
168
169
  A `critical` leaf in a nested composition still aborts the outermost root `template: [...]`. There is no per-branch scoping in the current standard.
169
170
 
171
+ ## Retry
172
+
173
+ Set `retry: N` on a leaf to attempt execution up to `N` times (including the first). Retries happen immediately on non-zero exit. The first successful attempt stops the retry loop.
174
+
175
+ ```json
176
+ {
177
+ "template": [
178
+ { "template": "npm install", "retry": 3 },
179
+ { "template": "npm test", "critical": true, "retry": 2 }
180
+ ]
181
+ }
182
+ ```
183
+
184
+ `npm install` is retried up to 3 times. `npm test` is retried up to 2 times; if all attempts fail, the critical step aborts the pipeline.
185
+
170
186
  ## Progressive Disclosure
171
187
 
172
188
  The standard uses a single `template` field that grows with the user's needs:
@@ -175,10 +191,10 @@ The standard uses a single `template` field that grows with the user's needs:
175
191
  string → leaf command
176
192
  string[] → sequential composition
177
193
  { template } → leaf with defaults
178
- { template, critical, output } → full leaf
194
+ { template, retry, critical, output } → full leaf
179
195
  ```
180
196
 
181
- Start with a string. Add composition when needed. Add critical when safety matters. Same contract, growing capability, no dead weight.
197
+ Start with a string. Add composition when needed. Add retry when flaky. Add critical when safety matters. Same contract, growing capability, no dead weight.
182
198
 
183
199
  ## Tool Boundary
184
200
 
package/docs/locks.md CHANGED
@@ -16,7 +16,7 @@ Path:
16
16
 
17
17
  ```json
18
18
  {
19
- "@llblab/pi-telegram": {
19
+ "@scope/pi-singleton": {
20
20
  "pid": 2590864,
21
21
  "cwd": "/home/user/project"
22
22
  }
@@ -38,9 +38,9 @@ For npm-style package extensions, the canonical value is the `package.json` `nam
38
38
  Examples:
39
39
 
40
40
  ```text
41
- extensions/pi-telegram/package.json name=@llblab/pi-telegram -> @llblab/pi-telegram
42
- extensions/pi-telegram/index.ts without package.json -> pi-telegram
43
- extensions/pi-telegram.ts -> pi-telegram
41
+ extensions/pi-singleton/package.json name=@scope/pi-singleton -> @scope/pi-singleton
42
+ extensions/pi-singleton/index.ts without package.json -> pi-singleton
43
+ extensions/pi-singleton.ts -> pi-singleton
44
44
  ```
45
45
 
46
46
  ## Required fields
@@ -62,7 +62,7 @@ During a user-initiated start/connect event, an extension should:
62
62
 
63
63
  ## Acquisition timing
64
64
 
65
- Lock writes must be caused by an explicit user-initiated runtime event, such as `/wakeup-start`, `/telegram-connect`, or a confirmed takeover prompt.
65
+ Lock writes must be caused by an explicit user-initiated runtime event, such as a start/connect command or a confirmed takeover prompt.
66
66
 
67
67
  Extension initialization and session-start hooks may read `locks.json`, update local status, install ownership watchers, and resume local work when the existing lock already points at the current `pid`/`cwd`. After a full process restart, a session-start hook may replace a stale lock from the same `cwd` to restore explicitly requested ownership. They must not create ownership from an inactive lock, take over a live external owner, or replace a stale lock from another directory by themselves. Such locks should stay visible as state until the user runs the start/connect command. Session replacement should suspend local runtime work and ownership watchers without releasing the lock, so the next session in the same `pid`/`cwd` can resume from explicit ownership.
68
68
 
@@ -92,11 +92,13 @@ Do not print optional fields in normal UI unless they help the user act.
92
92
 
93
93
  ## Runtime status
94
94
 
95
- Singleton extensions with footer/status presence should expose quiet but explicit local state. For example, pi-wakeup uses:
95
+ Singleton extensions with footer/status presence should expose quiet but explicit local state:
96
96
 
97
- - `wakeup off` when this pi instance does not own the singleton runtime
98
- - `wakeup on` when this pi instance owns the runtime but has no pending wake-up detail to show
99
- - `wakeup [16:32:39]` when the runtime owns scheduled work and can show the next countdown
97
+ - `off` when this pi instance does not own the singleton runtime
98
+ - `on` when this pi instance owns the runtime but has no pending runtime detail to show
99
+ - `[16:32:39]` when the runtime owns scheduled work and can show the next countdown
100
+
101
+ Extensions may prefix those states with their own compact name, such as `wakeup off` or `telegram on`.
100
102
 
101
103
  ## Interactive takeover
102
104
 
@@ -110,7 +112,7 @@ Start/connect commands should make singleton moves easy:
110
112
  Takeover prompts should use the extension name as the dialog title, then the question, a blank line, and source/target lines:
111
113
 
112
114
  ```text
113
- pi-telegram
115
+ pi-singleton
114
116
  move singleton lock here?
115
117
 
116
118
  from: pid 2590864, cwd /old
@@ -123,7 +125,7 @@ The previous owner may use `fs.watch`, mtime polling, or an existing status/time
123
125
 
124
126
  ## Reset
125
127
 
126
- Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files such as `telegram.json`.
128
+ Delete `~/.pi/agent/locks.json` to reset singleton runtime ownership for all participating extensions without deleting their configuration files.
127
129
 
128
130
  ## Atomicity
129
131
 
@@ -112,7 +112,7 @@ For composed handlers, `output` selects the primary artifact after the compositi
112
112
 
113
113
  For one-step `template` handlers, stdout remains the default result channel: the command should print the generated OGG/Opus path.
114
114
 
115
- **Critical steps:** voice synthesis is a multi-step pipeline (TTS → ffmpeg → OGG). The ffmpeg conversion step is inherently critical — if it fails, the voice output is invalid. Mark it as `"critical": true` when a composed handler must abort after conversion failure instead of continuing to later non-critical steps. Keep the fallback chain (Mistral TTS Groq TTS) as the safety net for persistent outages. See [Command Template Standard](./command-templates.md) for semantics.
115
+ **Critical steps:** voice synthesis is often a multi-step transform TTS → conversion pipeline. The final audio conversion step is inherently critical — if it fails, the voice output is invalid. Mark conversion steps as `"critical": true` when a composed handler must abort after conversion failure instead of continuing to later non-critical steps. Use multiple matching `type: "voice"` handlers when you need provider or command fallbacks. See [Command Template Standard](./command-templates.md) for semantics.
116
116
 
117
117
  ## Buttons Markup
118
118
 
package/index.ts CHANGED
@@ -49,6 +49,8 @@ export default function (pi: Pi.ExtensionAPI) {
49
49
  const { abort, lifecycle, queue, setup, typing } = bridgeRuntime;
50
50
  const configStore = Config.createTelegramConfigStore();
51
51
  const lockRuntime = Locks.createTelegramLockRuntime<Pi.ExtensionContext>();
52
+ const lockOwnershipGuard =
53
+ Locks.createTelegramLockOwnershipGuard(lockRuntime);
52
54
  const activeTurnRuntime = Queue.createTelegramActiveTurnStore();
53
55
  const buttonActionStore = OutboundHandlers.createTelegramButtonActionStore();
54
56
  const pendingModelSwitchStore =
@@ -198,6 +200,7 @@ export default function (pi: Pi.ExtensionAPI) {
198
200
  sendDraft: sendMessageDraft,
199
201
  sendMessage,
200
202
  editMessageText: editTelegramMessageText,
203
+ canSend: lockOwnershipGuard.ownsCurrentProcess,
201
204
  ...replyTransport,
202
205
  });
203
206
  const { finalizeMarkdownPreview } =
@@ -459,6 +462,7 @@ export default function (pi: Pi.ExtensionAPI) {
459
462
  sendQueuedAttachments: queuedAttachmentSender,
460
463
  planOutboundReply: outboundReplyPlanner,
461
464
  sendOutboundReplyArtifacts: outboundReplyArtifactSender,
465
+ isCurrentOwner: lockOwnershipGuard.ownsContext,
462
466
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
463
467
  setActiveToolExecutions: lifecycle.setActiveToolExecutions,
464
468
  triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,
@@ -258,6 +258,15 @@ function getRemainingTelegramInboundTimeout(
258
258
  return Math.max(1, timeout - (Date.now() - startedAt));
259
259
  }
260
260
 
261
+ function getTelegramInboundInitialCompositionStepTimeout(
262
+ handler: TelegramInboundHandlerConfig,
263
+ step: TelegramInboundCommandTemplateConfig,
264
+ ): number {
265
+ const timeout = getTelegramInboundHandlerTimeout(handler);
266
+ const stepTimeout = getTelegramInboundHandlerConfiguredTimeout(step);
267
+ return stepTimeout === undefined ? timeout : Math.min(stepTimeout, timeout);
268
+ }
269
+
261
270
  function getTelegramInboundCompositionStepTimeout(
262
271
  handler: TelegramInboundHandlerConfig,
263
272
  step: TelegramInboundCommandTemplateConfig,
@@ -421,7 +430,9 @@ async function executeTelegramTextHandler(
421
430
  output,
422
431
  cwd,
423
432
  deps,
424
- getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
433
+ index === 0
434
+ ? getTelegramInboundInitialCompositionStepTimeout(handler, step)
435
+ : getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
425
436
  );
426
437
  } catch (error) {
427
438
  if (typeof step === "object" && step.critical) throw error;
@@ -498,7 +509,9 @@ async function executeTelegramInboundHandler(
498
509
  cwd,
499
510
  deps,
500
511
  false,
501
- getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
512
+ index === 0
513
+ ? getTelegramInboundInitialCompositionStepTimeout(handler, step)
514
+ : getTelegramInboundCompositionStepTimeout(handler, step, startedAt),
502
515
  index === 0 ? undefined : output,
503
516
  );
504
517
  } catch (error) {
package/lib/locks.ts CHANGED
@@ -61,6 +61,11 @@ export interface TelegramLockRuntime<TContext extends TelegramLockContext> {
61
61
  owns: (ctx?: TelegramLockContext) => boolean;
62
62
  }
63
63
 
64
+ export interface TelegramLockOwnershipGuard<TContext extends TelegramLockContext> {
65
+ ownsCurrentProcess: () => boolean;
66
+ ownsContext: (ctx: TContext) => boolean;
67
+ }
68
+
64
69
  export interface TelegramLockRuntimeOptions {
65
70
  key?: string;
66
71
  locksPath?: string;
@@ -234,6 +239,17 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
234
239
  };
235
240
  }
236
241
 
242
+ export function createTelegramLockOwnershipGuard<
243
+ TContext extends TelegramLockContext,
244
+ >(
245
+ lock: TelegramLockRuntime<TContext>,
246
+ ): TelegramLockOwnershipGuard<TContext> {
247
+ return {
248
+ ownsCurrentProcess: () => lock.owns(),
249
+ ownsContext: (ctx) => lock.owns(ctx),
250
+ };
251
+ }
252
+
237
253
  export function createTelegramLockedPollingRuntime<
238
254
  TContext extends TelegramLockContext,
239
255
  >(
package/lib/menu-model.ts CHANGED
@@ -236,7 +236,7 @@ export interface TelegramModelMenuRuntime<
236
236
 
237
237
  export const TELEGRAM_MODEL_PAGE_SIZE = 6;
238
238
  const TELEGRAM_MODEL_PAGE_PICKER_ROW_SIZE = 4;
239
- export const MODEL_MENU_TITLE = "<b>Choose a model:</b>";
239
+ export const MODEL_MENU_TITLE = "<b>🤖 Choose a model:</b>";
240
240
  export const MODEL_PAGE_MENU_TITLE = "<b>Choose a page:</b>";
241
241
 
242
242
  function truncateTelegramButtonLabel(label: string, maxLength = 56): string {
package/lib/menu-queue.ts CHANGED
@@ -11,11 +11,14 @@ import * as Queue from "./queue.ts";
11
11
 
12
12
  // --- Queue Menu ---
13
13
 
14
+ const QUEUE_ITEM_PROMPT_HTML_LIMIT = 3600;
15
+ const QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX = "\n… [truncated]";
14
16
  type TelegramQueueMenuReplyMarkup = TelegramInlineKeyboardMarkup;
15
17
  interface TelegramQueueMenuItem {
16
18
  chatId: number;
17
19
  replyToMessageId: number;
18
20
  isPriority: boolean;
21
+ priorityEmoji?: string;
19
22
  hasAttachments: boolean;
20
23
  statusSummary: string;
21
24
  promptText: string;
@@ -40,6 +43,7 @@ function toTelegramQueueMenuItems<Context>(
40
43
  chatId: item.chatId,
41
44
  replyToMessageId: item.replyToMessageId,
42
45
  isPriority: item.queueLane === "priority",
46
+ priorityEmoji: item.kind === "prompt" ? item.priorityEmoji : undefined,
43
47
  hasAttachments:
44
48
  item.kind === "prompt" && item.queuedAttachments.length > 0,
45
49
  statusSummary: item.statusSummary,
@@ -53,7 +57,11 @@ function buildTelegramQueueMenuReplyMarkup(
53
57
  const backRow = [{ text: "⬆️ Main menu", callback_data: "menu:back" }];
54
58
  if (items.length === 0) return { inline_keyboard: [backRow] };
55
59
  const rows = items.map(function buildTelegramQueueMenuRow(item, index) {
56
- const prefix = item.isPriority ? "⚡ " : item.hasAttachments ? "📎 " : "";
60
+ const prefix = item.isPriority
61
+ ? `${item.priorityEmoji ?? "⚡"} `
62
+ : item.hasAttachments
63
+ ? "📎 "
64
+ : "";
57
65
  const label = `${index + 1}. ${prefix}${item.statusSummary}`;
58
66
  return [
59
67
  {
@@ -82,21 +90,44 @@ function findTelegramQueueMenuItem(
82
90
  return item.chatId === chatId && item.replyToMessageId === replyToMessageId;
83
91
  });
84
92
  }
93
+ function escapeTelegramQueueMenuHtmlChar(char: string): string {
94
+ if (char === "&") return "&amp;";
95
+ if (char === "<") return "&lt;";
96
+ if (char === ">") return "&gt;";
97
+ return char;
98
+ }
85
99
  function escapeTelegramQueueMenuHtml(text: string): string {
86
- return text
87
- .replace(/&/g, "&amp;")
88
- .replace(/</g, "&lt;")
89
- .replace(/>/g, "&gt;");
100
+ return Array.from(text).map(escapeTelegramQueueMenuHtmlChar).join("");
101
+ }
102
+ function escapeTelegramQueueMenuHtmlPreview(text: string): string {
103
+ const suffix = escapeTelegramQueueMenuHtml(QUEUE_ITEM_PROMPT_TRUNCATION_SUFFIX);
104
+ let escaped = "";
105
+ let truncated = false;
106
+ for (const char of text) {
107
+ const next = escapeTelegramQueueMenuHtmlChar(char);
108
+ if (
109
+ escaped.length + next.length + suffix.length >
110
+ QUEUE_ITEM_PROMPT_HTML_LIMIT
111
+ ) {
112
+ truncated = true;
113
+ break;
114
+ }
115
+ escaped += next;
116
+ }
117
+ return truncated ? escaped + suffix : escaped;
90
118
  }
91
119
  function getTelegramQueueMenuItemText(item: TelegramQueueMenuItem): string {
92
- return escapeTelegramQueueMenuHtml(item.promptText);
120
+ return `<pre>${escapeTelegramQueueMenuHtmlPreview(item.promptText)}</pre>`;
93
121
  }
94
122
  function buildTelegramQueueItemSubmenuReplyMarkup(
95
123
  chatId: number,
96
124
  replyToMessageId: number,
97
125
  isPriority: boolean,
126
+ priorityEmoji?: string,
98
127
  ): TelegramQueueMenuReplyMarkup {
99
- const priorityLabel = isPriority ? "🐢 Deprioritize" : "⚡ Prioritize";
128
+ const priorityLabel = isPriority
129
+ ? `🐢 Deprioritize ${priorityEmoji ?? "⚡"}`
130
+ : "⚡ Prioritize";
100
131
  return {
101
132
  inline_keyboard: [
102
133
  [{ text: "⬆️ Back", callback_data: "queue:list" }],
@@ -108,8 +139,29 @@ function buildTelegramQueueItemSubmenuReplyMarkup(
108
139
  ],
109
140
  [
110
141
  {
111
- text: " Cancel",
112
- callback_data: `queue:cancel:${chatId}:${replyToMessageId}`,
142
+ text: "🗑 Delete",
143
+ callback_data: `queue:delete:${chatId}:${replyToMessageId}`,
144
+ },
145
+ ],
146
+ ],
147
+ };
148
+ }
149
+ function buildTelegramQueueDeleteConfirmationReplyMarkup(
150
+ chatId: number,
151
+ replyToMessageId: number,
152
+ ): TelegramQueueMenuReplyMarkup {
153
+ return {
154
+ inline_keyboard: [
155
+ [
156
+ {
157
+ text: "🗑 Yes, delete",
158
+ callback_data: `queue:confirm-delete:${chatId}:${replyToMessageId}`,
159
+ },
160
+ ],
161
+ [
162
+ {
163
+ text: "❌ No",
164
+ callback_data: `queue:keep:${chatId}:${replyToMessageId}`,
113
165
  },
114
166
  ],
115
167
  ],
@@ -186,14 +238,38 @@ async function handleTelegramQueueMenuCallback<Context>(
186
238
  );
187
239
  return true;
188
240
  }
189
- const cancelMatch = data.match(/^queue:cancel:(\d+):(\d+)$/);
190
- if (cancelMatch) {
191
- await handleTelegramQueueMenuCancel(
241
+ const deleteMatch = data.match(/^queue:(?:delete|cancel):(\d+):(\d+)$/);
242
+ if (deleteMatch) {
243
+ await handleTelegramQueueMenuDeleteRequest(
192
244
  callbackQueryId,
193
245
  replyChatId,
194
246
  replyMessageId,
195
- Number(cancelMatch[1]),
196
- Number(cancelMatch[2]),
247
+ Number(deleteMatch[1]),
248
+ Number(deleteMatch[2]),
249
+ deps,
250
+ );
251
+ return true;
252
+ }
253
+ const keepMatch = data.match(/^queue:keep:(\d+):(\d+)$/);
254
+ if (keepMatch) {
255
+ await handleTelegramQueueMenuKeep(
256
+ callbackQueryId,
257
+ replyChatId,
258
+ replyMessageId,
259
+ Number(keepMatch[1]),
260
+ Number(keepMatch[2]),
261
+ deps,
262
+ );
263
+ return true;
264
+ }
265
+ const confirmDeleteMatch = data.match(/^queue:confirm-delete:(\d+):(\d+)$/);
266
+ if (confirmDeleteMatch) {
267
+ await handleTelegramQueueMenuConfirmDelete(
268
+ callbackQueryId,
269
+ replyChatId,
270
+ replyMessageId,
271
+ Number(confirmDeleteMatch[1]),
272
+ Number(confirmDeleteMatch[2]),
197
273
  ctx,
198
274
  deps,
199
275
  );
@@ -204,8 +280,8 @@ async function handleTelegramQueueMenuCallback<Context>(
204
280
  function getTelegramQueueMenuListText(
205
281
  items: readonly TelegramQueueMenuItem[],
206
282
  ): string {
207
- if (items.length === 0) return "<b>Queue is empty.</b>";
208
- return "<b>Queue:</b>";
283
+ if (items.length === 0) return "<b>⏳ Queue is empty.</b>";
284
+ return "<b>⏳ Queue:</b>";
209
285
  }
210
286
  async function updateTelegramQueueMenuList<Context>(
211
287
  callbackQueryId: string,
@@ -258,7 +334,12 @@ async function handleTelegramQueueMenuPick<Context>(
258
334
  replyChatId,
259
335
  replyMessageId,
260
336
  getTelegramQueueMenuItemText(item),
261
- buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, item.isPriority),
337
+ buildTelegramQueueItemSubmenuReplyMarkup(
338
+ chatId,
339
+ msgId,
340
+ item.isPriority,
341
+ item.priorityEmoji,
342
+ ),
262
343
  );
263
344
  await deps.answerCallbackQuery(callbackQueryId);
264
345
  }
@@ -288,14 +369,74 @@ async function handleTelegramQueueMenuPriority<Context>(
288
369
  replyChatId,
289
370
  replyMessageId,
290
371
  getTelegramQueueMenuItemText(item),
291
- buildTelegramQueueItemSubmenuReplyMarkup(chatId, msgId, newPriority),
372
+ buildTelegramQueueItemSubmenuReplyMarkup(
373
+ chatId,
374
+ msgId,
375
+ newPriority,
376
+ updated?.priorityEmoji ?? item.priorityEmoji,
377
+ ),
292
378
  );
293
379
  await deps.answerCallbackQuery(
294
380
  callbackQueryId,
295
381
  newPriority ? "Prioritized." : "Deprioritized.",
296
382
  );
297
383
  }
298
- async function handleTelegramQueueMenuCancel<Context>(
384
+ async function handleTelegramQueueMenuDeleteRequest<Context>(
385
+ callbackQueryId: string,
386
+ replyChatId: number,
387
+ replyMessageId: number,
388
+ chatId: number,
389
+ msgId: number,
390
+ deps: TelegramQueueMenuCallbackDeps<Context>,
391
+ ): Promise<void> {
392
+ const item = deps.findItem(chatId, msgId);
393
+ if (!item) {
394
+ return refreshStaleTelegramQueueMenuItem(
395
+ callbackQueryId,
396
+ replyChatId,
397
+ replyMessageId,
398
+ deps,
399
+ );
400
+ }
401
+ await deps.updateQueueMessage(
402
+ replyChatId,
403
+ replyMessageId,
404
+ "<b>Delete this queued prompt?</b>",
405
+ buildTelegramQueueDeleteConfirmationReplyMarkup(chatId, msgId),
406
+ );
407
+ await deps.answerCallbackQuery(callbackQueryId);
408
+ }
409
+ async function handleTelegramQueueMenuKeep<Context>(
410
+ callbackQueryId: string,
411
+ replyChatId: number,
412
+ replyMessageId: number,
413
+ chatId: number,
414
+ msgId: number,
415
+ deps: TelegramQueueMenuCallbackDeps<Context>,
416
+ ): Promise<void> {
417
+ const item = deps.findItem(chatId, msgId);
418
+ if (!item) {
419
+ return refreshStaleTelegramQueueMenuItem(
420
+ callbackQueryId,
421
+ replyChatId,
422
+ replyMessageId,
423
+ deps,
424
+ );
425
+ }
426
+ await deps.updateQueueMessage(
427
+ replyChatId,
428
+ replyMessageId,
429
+ getTelegramQueueMenuItemText(item),
430
+ buildTelegramQueueItemSubmenuReplyMarkup(
431
+ chatId,
432
+ msgId,
433
+ item.isPriority,
434
+ item.priorityEmoji,
435
+ ),
436
+ );
437
+ await deps.answerCallbackQuery(callbackQueryId, "Kept in queue.");
438
+ }
439
+ async function handleTelegramQueueMenuConfirmDelete<Context>(
299
440
  callbackQueryId: string,
300
441
  replyChatId: number,
301
442
  replyMessageId: number,
@@ -311,7 +452,7 @@ async function handleTelegramQueueMenuCancel<Context>(
311
452
  replyChatId,
312
453
  replyMessageId,
313
454
  deps,
314
- removed ? "Removed from queue." : "Item not found.",
455
+ removed ? "Deleted from queue." : "Item not found.",
315
456
  );
316
457
  }
317
458
 
@@ -160,7 +160,7 @@ export function buildStatusReplyMarkup(
160
160
  }
161
161
  rows.push([
162
162
  {
163
- text: `🔢 Queue: ${queueItemCount}`,
163
+ text: `⏳ Queue: ${queueItemCount}`,
164
164
  callback_data: "menu:queue",
165
165
  },
166
166
  ]);
@@ -109,7 +109,7 @@ export async function handleTelegramThinkingMenuCallbackAction(
109
109
  }
110
110
 
111
111
  export function buildThinkingMenuText(): string {
112
- return "<b>Choose a thinking level:</b>";
112
+ return "<b>🧠 Choose a thinking level:</b>";
113
113
  }
114
114
 
115
115
  export function buildThinkingMenuReplyMarkup(
@@ -650,7 +650,7 @@ async function generateTelegramVoiceReplyFileWithHandler(
650
650
  const steps = getTelegramVoiceHandlerCompositionSteps(options.handler);
651
651
  if (steps.length > 0) {
652
652
  const startedAt = Date.now();
653
- let stdout = "";
653
+ let stdout = text;
654
654
  for (const [index, step] of steps.entries()) {
655
655
  try {
656
656
  const result = await runVoiceReplyCommand(
@@ -665,7 +665,7 @@ async function generateTelegramVoiceReplyFileWithHandler(
665
665
  startedAt,
666
666
  ),
667
667
  execCommand: options.execCommand,
668
- ...(index === 0 ? {} : { stdin: stdout }),
668
+ stdin: stdout,
669
669
  },
670
670
  );
671
671
  stdout = result.stdout;
package/lib/preview.ts CHANGED
@@ -89,6 +89,7 @@ export interface TelegramPreviewRuntimeDeps<
89
89
  chunks: TelegramRenderedChunk[],
90
90
  options?: { replyMarkup?: TReplyMarkup },
91
91
  ) => Promise<number | undefined>;
92
+ canSend?: () => boolean;
92
93
  }
93
94
 
94
95
  export interface TelegramPreviewActiveTurn {
@@ -182,6 +183,7 @@ export interface TelegramPreviewControllerDeps<
182
183
  chunks: TelegramRenderedChunk[],
183
184
  options?: { replyMarkup?: TReplyMarkup },
184
185
  ) => Promise<number | undefined>;
186
+ canSend?: () => boolean;
185
187
  throttleMs?: number;
186
188
  maxDraftId?: number;
187
189
  setTimer?: (
@@ -418,6 +420,7 @@ export function createTelegramPreviewController<
418
420
  options,
419
421
  ),
420
422
  editRenderedMessage: deps.editRenderedMessage,
423
+ canSend: deps.canSend,
421
424
  });
422
425
  return {
423
426
  getState: () => state,
@@ -574,6 +577,10 @@ async function performTelegramPreviewFlush<
574
577
  state: TelegramPreviewRuntimeState,
575
578
  deps: TelegramPreviewRuntimeDeps<TReplyMarkup>,
576
579
  ): Promise<void> {
580
+ if (deps.canSend && !deps.canSend()) {
581
+ await clearTelegramPreview(chatId, deps);
582
+ return;
583
+ }
577
584
  const snapshot = buildTelegramPreviewSnapshot({
578
585
  state,
579
586
  maxMessageLength: deps.maxMessageLength,
@@ -658,6 +665,10 @@ export async function finalizeTelegramPreview<
658
665
  ): Promise<boolean> {
659
666
  const state = deps.getState();
660
667
  if (!state) return false;
668
+ if (deps.canSend && !deps.canSend()) {
669
+ await clearTelegramPreview(chatId, deps);
670
+ return false;
671
+ }
661
672
  await flushTelegramPreview(chatId, deps);
662
673
  const finalText = buildTelegramPreviewFinalText(state);
663
674
  if (!finalText) {
@@ -683,6 +694,10 @@ export async function finalizeTelegramMarkdownPreview<
683
694
  ): Promise<boolean> {
684
695
  const state = deps.getState();
685
696
  if (!state) return false;
697
+ if (deps.canSend && !deps.canSend()) {
698
+ await clearTelegramPreview(chatId, deps);
699
+ return false;
700
+ }
686
701
  await flushTelegramPreview(chatId, deps);
687
702
  const chunks = deps.renderTelegramMessage(markdown, { mode: "markdown" });
688
703
  if (chunks.length === 0) {
package/lib/queue.ts CHANGED
@@ -78,6 +78,7 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
78
78
  queuedAttachments: QueuedAttachment[];
79
79
  content: TelegramPromptContent[];
80
80
  historyText: string;
81
+ priorityEmoji?: string;
81
82
  }
82
83
 
83
84
  export interface PendingTelegramControlItem<
@@ -304,6 +305,7 @@ export function clearTelegramQueuePromptPriority<TContext = unknown>(
304
305
  ...item,
305
306
  queueLane: "default" as const,
306
307
  laneOrder: item.queueOrder,
308
+ priorityEmoji: undefined,
307
309
  };
308
310
  });
309
311
  return { items: nextItems, changed };
@@ -313,6 +315,7 @@ export function prioritizeTelegramQueuePrompt<TContext = unknown>(
313
315
  items: TelegramQueueItem<TContext>[],
314
316
  messageId: number,
315
317
  laneOrder: number,
318
+ priorityEmoji = "⚡",
316
319
  ): { items: TelegramQueueItem<TContext>[]; changed: boolean } {
317
320
  let changed = false;
318
321
  const nextItems = items.map((item) => {
@@ -327,6 +330,7 @@ export function prioritizeTelegramQueuePrompt<TContext = unknown>(
327
330
  ...item,
328
331
  queueLane: "priority" as const,
329
332
  laneOrder,
333
+ priorityEmoji,
330
334
  };
331
335
  });
332
336
  return { items: nextItems, changed };
@@ -353,7 +357,7 @@ function formatTelegramQueueItemStatusSummary<TContext = unknown>(
353
357
  item: TelegramQueueItem<TContext>,
354
358
  ): string {
355
359
  if (item.queueLane === "priority") {
356
- return `⚡ ${item.statusSummary}`;
360
+ return `${item.kind === "prompt" ? item.priorityEmoji ?? "⚡" : "⚡"} ${item.statusSummary}`;
357
361
  }
358
362
  return item.statusSummary;
359
363
  }
@@ -767,6 +771,7 @@ export interface TelegramAgentEndRuntimeDeps<
767
771
  preserveQueuedTurnsAsHistory: boolean;
768
772
  resetRuntimeState: () => void;
769
773
  updateStatus: () => void;
774
+ isCurrentOwner?: () => boolean;
770
775
  dispatchNextQueuedTelegramTurn: () => void;
771
776
  clearPreview: (chatId: number) => Promise<void>;
772
777
  setPreviewPendingText: (text: string) => void;
@@ -811,6 +816,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
811
816
  getPreserveQueuedTurnsAsHistory: () => boolean;
812
817
  resetRuntimeState: () => void;
813
818
  updateStatus: (ctx: TContext) => void;
819
+ isCurrentOwner?: (ctx: TContext) => boolean;
814
820
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
815
821
  requestDeferredDispatchNextQueuedTelegramTurn: (
816
822
  dispatch: (ctx: TContext) => void,
@@ -928,6 +934,9 @@ export function createTelegramAgentEndHook<
928
934
  preserveQueuedTurnsAsHistory: deps.getPreserveQueuedTurnsAsHistory(),
929
935
  resetRuntimeState: deps.resetRuntimeState,
930
936
  updateStatus: () => deps.updateStatus(ctx),
937
+ isCurrentOwner: deps.isCurrentOwner
938
+ ? () => deps.isCurrentOwner?.(ctx) ?? false
939
+ : undefined,
931
940
  dispatchNextQueuedTelegramTurn: () => {
932
941
  deps.requestDeferredDispatchNextQueuedTelegramTurn(
933
942
  deps.dispatchNextQueuedTelegramTurn,
@@ -960,6 +969,10 @@ export async function handleTelegramAgentEndRuntime<
960
969
  const replyMarkup = outboundReply?.replyMarkup;
961
970
  deps.resetRuntimeState();
962
971
  deps.updateStatus();
972
+ if (turn && deps.isCurrentOwner && !deps.isCurrentOwner()) {
973
+ await deps.clearPreview(turn.chatId);
974
+ return;
975
+ }
963
976
  const endPlan = buildTelegramAgentEndPlan({
964
977
  hasTurn: !!turn,
965
978
  stopReason: assistant.stopReason,
@@ -1161,7 +1174,11 @@ export interface TelegramQueueMutationController<TContext> {
1161
1174
  clear: (ctx: TContext) => number;
1162
1175
  removeByMessageIds: (messageIds: number[], ctx: TContext) => number;
1163
1176
  clearPriorityByMessageId: (messageId: number, ctx: TContext) => boolean;
1164
- prioritizeByMessageId: (messageId: number, ctx: TContext) => boolean;
1177
+ prioritizeByMessageId: (
1178
+ messageId: number,
1179
+ ctx: TContext,
1180
+ priorityEmoji?: string,
1181
+ ) => boolean;
1165
1182
  }
1166
1183
 
1167
1184
  export interface TelegramControlQueueControllerDeps<TContext> {
@@ -1375,8 +1392,12 @@ export function createTelegramQueueMutationController<TContext>(
1375
1392
  ),
1376
1393
  clearPriorityByMessageId: (messageId, ctx) =>
1377
1394
  clearTelegramQueuePromptPriorityRuntime(messageId, buildRuntimeDeps(ctx)),
1378
- prioritizeByMessageId: (messageId, ctx) =>
1379
- prioritizeTelegramQueuePromptRuntime(messageId, buildRuntimeDeps(ctx)),
1395
+ prioritizeByMessageId: (messageId, ctx, priorityEmoji) =>
1396
+ prioritizeTelegramQueuePromptRuntime(
1397
+ messageId,
1398
+ buildRuntimeDeps(ctx),
1399
+ priorityEmoji,
1400
+ ),
1380
1401
  };
1381
1402
  }
1382
1403
 
@@ -1438,6 +1459,7 @@ export function clearTelegramQueuePromptPriorityRuntime<TContext>(
1438
1459
  export function prioritizeTelegramQueuePromptRuntime<TContext>(
1439
1460
  messageId: number,
1440
1461
  deps: TelegramQueueMutationRuntimeDeps<TContext>,
1462
+ priorityEmoji?: string,
1441
1463
  ): boolean {
1442
1464
  const nextPriorityReactionOrder = deps.getNextPriorityReactionOrder?.();
1443
1465
  if (nextPriorityReactionOrder === undefined) return false;
@@ -1445,6 +1467,7 @@ export function prioritizeTelegramQueuePromptRuntime<TContext>(
1445
1467
  deps.getQueuedItems(),
1446
1468
  messageId,
1447
1469
  nextPriorityReactionOrder,
1470
+ priorityEmoji,
1448
1471
  );
1449
1472
  if (!changed) return false;
1450
1473
  deps.setQueuedItems(items);
package/lib/updates.ts CHANGED
@@ -26,18 +26,23 @@ export type TelegramReactionType =
26
26
  | TelegramReactionTypeEmoji
27
27
  | TelegramReactionTypeNonEmoji;
28
28
 
29
- export const TELEGRAM_PRIORITY_REACTION_EMOJIS = [
30
- "👍",
31
- "⚡",
32
- "❤",
33
- "🕊",
29
+ export const TELEGRAM_PRIORITY_REACTIONS = [
30
+ { id: 10, name: "like", emoji: "👍" },
31
+ { id: 11, name: "lightning", emoji: "⚡" },
32
+ { id: 12, name: "heart", emoji: "❤" },
33
+ { id: 13, name: "dove", emoji: "🕊" },
34
34
  ] as const;
35
- export const TELEGRAM_REMOVAL_REACTION_EMOJIS = [
36
- "👎",
37
- "👻",
38
- "💔",
39
- "💩",
35
+ export const TELEGRAM_REMOVAL_REACTIONS = [
36
+ { id: 20, name: "dislike", emoji: "👎" },
37
+ { id: 21, name: "ghost", emoji: "👻" },
38
+ { id: 22, name: "broken-heart", emoji: "💔" },
39
+ { id: 23, name: "poop", emoji: "💩" },
40
40
  ] as const;
41
+ export const TELEGRAM_PRIORITY_REACTION_EMOJIS =
42
+ TELEGRAM_PRIORITY_REACTIONS.map((reaction) => reaction.emoji);
43
+ export const TELEGRAM_REMOVAL_REACTION_EMOJIS = TELEGRAM_REMOVAL_REACTIONS.map(
44
+ (reaction) => reaction.emoji,
45
+ );
41
46
 
42
47
  export interface TelegramUpdateDeletion {
43
48
  deleted_business_messages?: { message_ids?: unknown };
@@ -71,15 +76,22 @@ function hasAnyTelegramReactionEmoji(
71
76
  return candidates.some((emoji) => emojis.has(emoji));
72
77
  }
73
78
 
74
- function hasAddedTelegramReactionEmoji(
79
+ function getAddedTelegramReactionEmoji(
75
80
  oldEmojis: Set<string>,
76
81
  newEmojis: Set<string>,
77
82
  candidates: readonly string[],
78
- ): boolean {
79
- return candidates.some(
83
+ ): string | undefined {
84
+ return candidates.find(
80
85
  (emoji) => !oldEmojis.has(emoji) && newEmojis.has(emoji),
81
86
  );
82
87
  }
88
+ function hasAddedTelegramReactionEmoji(
89
+ oldEmojis: Set<string>,
90
+ newEmojis: Set<string>,
91
+ candidates: readonly string[],
92
+ ): boolean {
93
+ return !!getAddedTelegramReactionEmoji(oldEmojis, newEmojis, candidates);
94
+ }
83
95
 
84
96
  export function extractDeletedTelegramMessageIds(
85
97
  update: TelegramUpdateDeletion,
@@ -410,6 +422,7 @@ export interface TelegramUpdateRuntimeControllerDeps<
410
422
  prioritizeQueuedTelegramTurnByMessageId: (
411
423
  messageId: number,
412
424
  ctx: TContext,
425
+ priorityEmoji?: string,
413
426
  ) => boolean;
414
427
  pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
415
428
  answerCallbackQuery: (
@@ -592,6 +605,7 @@ export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
592
605
  prioritizeQueuedTelegramTurnByMessageId: (
593
606
  messageId: number,
594
607
  ctx: TContext,
608
+ priorityEmoji?: string,
595
609
  ) => boolean;
596
610
  }
597
611
 
@@ -638,17 +652,16 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
638
652
  deps.ctx,
639
653
  );
640
654
  }
641
- if (
642
- !hasAddedTelegramReactionEmoji(
643
- oldEmojis,
644
- newEmojis,
645
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
646
- )
647
- )
648
- return;
655
+ const addedPriorityEmoji = getAddedTelegramReactionEmoji(
656
+ oldEmojis,
657
+ newEmojis,
658
+ TELEGRAM_PRIORITY_REACTION_EMOJIS,
659
+ );
660
+ if (!addedPriorityEmoji) return;
649
661
  deps.prioritizeQueuedTelegramTurnByMessageId(
650
662
  reactionUpdate.message_id,
651
663
  deps.ctx,
664
+ addedPriorityEmoji,
652
665
  );
653
666
  }
654
667
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "private": false,
5
5
  "description": "Better Telegram DM bridge extension for π",
6
6
  "type": "module",