@llblab/pi-telegram 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  No open changes.
6
6
 
7
+ ## 0.16.1: Disconnected Queue Status Hotfix
8
+
9
+ - `[Status]` Keep showing the local Telegram queue count in the TUI status bar when polling ownership moves to another Pi instance and the bridge reads as disconnected. Impact: the singleton Telegram control lock can move without hiding pending session-local Telegram prompt work from the original agent.
10
+
11
+ ## 0.16.0: Telegram Extension Commands
12
+
13
+ - `[API]` Added `registerTelegramCommand()` on the public `/commands` subpath so companion extensions can explicitly provide Telegram-native slash commands without adding workflow-specific commands to core. Built-in bridge commands stay reserved, extension command names must be Bot API safe, duplicate extension names are rejected, commands stay hidden unless `showInMenu` is enabled, visible commands must provide an emoji used in `/start` help and Bot API descriptions, extension-command descriptions are shown in `/start`, visible extension commands are inserted after `/compact` before queue-control commands, prompt-template commands remain separated in `/start`, handler failures are isolated with runtime diagnostics, and routing precedence is built-ins → extension commands → prompt-template aliases. Impact: commands such as fresh-session controls can live in companion extensions while `pi-telegram` remains a lightweight Telegram shell.
14
+
7
15
  ## 0.15.1: Typing Keepalive Cadence
8
16
 
9
17
  - `[Typing Status]` Pinned the default native Telegram typing keepalive interval at 2500 ms while preserving the 250 ms idle-drain cap. Impact: runtime behavior matches the intended conservative chat-action cadence.
package/README.md CHANGED
@@ -114,7 +114,7 @@ If you ask π for a generated file, π can call `telegram_attach`: during a Tele
114
114
 
115
115
  ### Operator menu and controls
116
116
 
117
- The inline application menu is the primary operator surface. It exposes status, prompt-template commands, model selection, thinking level selection, settings, and queue inspection/mutation: a Telegram-shaped subset of the important handles normally available from the CLI. A typical control loop stays inside Telegram: open `/start`, inspect status, jump into Queue, delete stale work, switch model, return to the main menu, and keep the π session running without touching the terminal.
117
+ The inline application menu is the primary operator surface. It exposes status, prompt-template commands, companion-extension Telegram commands, model selection, thinking level selection, settings, and queue inspection/mutation: a Telegram-shaped subset of the important handles normally available from the CLI. A typical control loop stays inside Telegram: open `/start`, inspect status, jump into Queue, delete stale work, switch model, return to the main menu, and keep the π session running without touching the terminal.
118
118
 
119
119
  ### Queue runtime
120
120
 
@@ -209,7 +209,7 @@ Unknown inline-button callbacks are forwarded to π as `[callback] <data>` when
209
209
 
210
210
  ### Extension Sections
211
211
 
212
- Ordinary pi extensions can register structured UI sections that appear in the main Telegram menu and Settings submenu without owning a second polling loop. Companion extensions can also register compact status lines for the `/start` menu status text, allowing widgets such as quota indicators to appear beside Status, Usage, Cost, and Context only when relevant to the active model. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
212
+ Ordinary pi extensions can register Telegram-native slash commands, structured UI sections, and compact status lines without owning a second polling loop. Slash commands use explicit opt-in registration from `@llblab/pi-telegram/commands`, so workflow-specific commands such as a fresh-session control can live in companion extensions instead of expanding the core bridge command set. UI sections appear in the main Telegram menu and Settings submenu, while status lines allow widgets such as quota indicators to appear beside Status, Usage, Cost, and Context only when relevant to the active model. Each section gets a narrow typed context with `edit`, `open`, `enqueuePrompt`, `answerCallback`, and `callbackData()` — enough to build interactive Telegram-native surfaces while `pi-telegram` owns transport, callback routing, navigation hierarchy, and diagnostics.
213
213
 
214
214
  Import `registerTelegramSection()` from `@llblab/pi-telegram/sections` and return a disposer on shutdown. Sections can send interactive messages directly into the chat via `ctx.open()` — confirmation dialogs, approve/deny gates, and multi-step forms live outside the menu hierarchy while callbacks route through the same typed handler. See [`@llblab/pi-telegram-extension-demo`](https://github.com/llblab/pi-telegram-extension-demo) for a working reference and the [Extension Sections Standard](./docs/sections.md) for the full contract.
215
215
 
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public Telegram commands API
3
+ * Zones: package boundary, companion extension interop
4
+ * Exposes the stable Telegram slash-command registration surface while keeping registry internals package-private
5
+ */
6
+
7
+ export {
8
+ registerTelegramCommand,
9
+ type TelegramExtensionCommandContext,
10
+ type TelegramExtensionCommandRegistration,
11
+ } from "../lib/commands.ts";
package/docs/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Living index of project documentation in `/docs`.
4
4
 
5
+ `public-api.md` is the canonical entrypoint for stable extension surfaces. Focused documents exist only when a surface has enough runtime rules to need a dedicated standard.
6
+
5
7
  ## Documents
6
8
 
7
9
  - [architecture.md](./architecture.md) — Overview of the Telegram bridge runtime, queueing model, rendering pipeline, and interactive controls
@@ -207,12 +207,13 @@ Unknown callback data outside owned prefixes is forwarded as `[callback] <data>`
207
207
  `pi-telegram` intentionally owns one `getUpdates` loop per bot. `polling` owns that internal loop; `updates` owns classification/default-routing plans plus the public handler registry layered extensions use to observe or consume updates without opening a competing polling connection. Layered extensions should integrate through extension surfaces instead of polling the same bot independently.
208
208
 
209
209
  - Raw update observation/consumption: [Updates](./updates.md).
210
+ - Telegram-native slash commands: `registerTelegramCommand()` from [Public API](./public-api.md#commands).
210
211
  - Structured inline UI sections: [Sections](./sections.md).
211
212
  - Callback namespace discipline: [Callback Namespaces](./callback-namespaces.md).
212
213
  - Voice/STT/TTS providers: [Voice Integration](./voice.md).
213
214
  - Inbound/outbound command-template handlers: [Command Templates](./command-templates.md).
214
215
 
215
- Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`.
216
+ Extension callbacks must avoid `pi-telegram` owned prefixes such as `compact:`, `tgbtn:`, `menu:`, `model:`, `thinking:`, `status:`, `queue:`, `settings:`, and `section:`. Workflow-specific Telegram slash commands should use the public command registry instead of becoming new core built-ins unless they are bridge lifecycle, transport ownership, queue safety, or essential operator controls.
216
217
 
217
218
  ## Diagnostics And Operational Behavior
218
219
 
@@ -18,6 +18,7 @@ import telegram from "@llblab/pi-telegram";
18
18
  import { registerTelegramSection } from "@llblab/pi-telegram/sections";
19
19
  import { registerTelegramStatusLineProvider } from "@llblab/pi-telegram/status";
20
20
  import { registerTelegramUpdateHandler } from "@llblab/pi-telegram/updates";
21
+ import { registerTelegramCommand } from "@llblab/pi-telegram/commands";
21
22
  import { registerTelegramInboundHandler } from "@llblab/pi-telegram/inbound";
22
23
  import { registerTelegramOutboundHandler } from "@llblab/pi-telegram/outbound";
23
24
  import {
@@ -26,7 +27,7 @@ import {
26
27
  } from "@llblab/pi-telegram/voice";
27
28
  ```
28
29
 
29
- `0.12.0` intentionally removes the published `@llblab/pi-telegram/lib/*.ts` compatibility wildcard. Integrations should use the public API domain subpaths above. Package exports point at `/api/*.ts` membranes that re-export only stable companion-extension symbols; implementation modules under `lib/` remain package-private. See [Public API Smoke Examples](#public-api-smoke-examples) below for minimal companion-extension patterns that avoid implementation imports.
30
+ `0.12.0` intentionally removes the published `@llblab/pi-telegram/lib/*.ts` compatibility wildcard. Integrations should use the public API domain subpaths above. Package exports point at `/api/*.ts` membranes that re-export only stable companion-extension symbols; implementation modules under `lib/` remain package-private. Telegram command extensions use `/commands` as an explicit opt-in surface instead of automatically exposing arbitrary π slash commands to Telegram. See [Public API Smoke Examples](#public-api-smoke-examples) below for minimal companion-extension patterns that avoid implementation imports.
30
31
 
31
32
  ## User-Facing API
32
33
 
@@ -103,6 +104,9 @@ High-level stable APIs:
103
104
  - `registerTelegramSection()`
104
105
  - Identity: required `id`.
105
106
  - Purpose: managed menu/settings UI surfaces.
107
+ - `registerTelegramCommand()`
108
+ - Identity: command name.
109
+ - Purpose: explicit opt-in Telegram-native slash commands for companion workflows.
106
110
  - `registerTelegramStatusLineProvider()`
107
111
  - Identity: required `id`.
108
112
  - Purpose: compact companion status rows in the `/start` menu status text.
@@ -133,6 +137,34 @@ Advanced stable diagnostics:
133
137
 
134
138
  All registration APIs return a disposer. Companion extensions should call disposers on shutdown and re-register on session start when they recreate runtime state. Low-level bus APIs intentionally avoid ids and run in registration order. High-level provider/UI APIs require stable identity in their public contract so diagnostics, replacement, and cleanup are understandable. Generated voice-provider ids remain a temporary compatibility path where documented.
135
139
 
140
+ ## Commands
141
+
142
+ Import from `@llblab/pi-telegram/commands`. This registers Telegram slash commands only; it does not expose π slash commands and is unrelated to command-template handlers.
143
+
144
+ ```ts
145
+ const off = registerTelegramCommand({
146
+ name: "new",
147
+ description: "Start fresh",
148
+ showInMenu: true,
149
+ emoji: "🆕",
150
+ handler: async (ctx) => {
151
+ await ctx.reply("Starting a fresh session is handled by my extension.");
152
+ },
153
+ });
154
+ ```
155
+
156
+ Contract:
157
+
158
+ - Command names are Telegram Bot API names: lowercase `a-z`, digits, and `_`, up to 32 characters. Hyphenated names are rejected.
159
+ - Built-in bridge commands such as `/start`, `/compact`, `/next`, `/abort`, and `/stop` are reserved and cannot be claimed by extensions.
160
+ - Duplicate extension command names are rejected. The disposer removes only its own command registration.
161
+ - Routing precedence is built-in bridge commands first, registered extension commands second, and prompt-template aliases after that. This lets an extension intentionally claim a command name; prompt-template owners can resolve collisions by renaming the template alias.
162
+ - `showInMenu` defaults to `false`. When `true`, `emoji` is required and the command appears in `/start` help with that marker; it also joins Bot API command sync only when `description` is provided, because Telegram command-list entries require descriptions. The emoji is prefixed to the Bot API description as well. Workflow/product commands should opt in deliberately instead of expanding the core command row by default.
163
+ - The command context currently provides `name`, `args`, `reply(text)`, and `enqueuePrompt(prompt)`. Use `enqueuePrompt()` when a command should create normal queued π work rather than perform immediate Telegram-side handling.
164
+ - Handler failures are isolated: the bridge records a `telegram-command` runtime diagnostic, sends a compact failure reply, and keeps Telegram polling/routing alive.
165
+
166
+ Core commands stay reserved for bridge lifecycle, transport ownership, queue safety, and essential operator controls. Opinionated workflow commands should live in companion extensions through this registry.
167
+
136
168
  ## Sections
137
169
 
138
170
  Import from `@llblab/pi-telegram/sections`.
package/index.ts CHANGED
@@ -256,7 +256,7 @@ export default function (pi: Pi.ExtensionAPI) {
256
256
  const getPromptTemplateCommands =
257
257
  PromptTemplates.createTelegramPromptTemplateCommandGetter({
258
258
  getCommands,
259
- reservedCommandNames: Commands.TELEGRAM_RESERVED_COMMAND_NAMES,
259
+ getReservedCommandNames: Commands.getTelegramReservedCommandNames,
260
260
  });
261
261
  const menuActions = Menu.createTelegramMenuActionRuntimeWithStateBuilder({
262
262
  runtime: modelMenuRuntime,
package/lib/commands.ts CHANGED
@@ -27,6 +27,130 @@ export interface TelegramPromptTemplateMenuCommand {
27
27
  description?: string;
28
28
  }
29
29
 
30
+ const TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY = "__piTelegramCommandRegistry__";
31
+ const TELEGRAM_BOT_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
32
+
33
+ export interface TelegramExtensionCommandContext {
34
+ name: string;
35
+ args: string;
36
+ reply: (text: string) => Promise<void>;
37
+ enqueuePrompt: (prompt: string) => Promise<void>;
38
+ }
39
+
40
+ export interface TelegramExtensionCommandRegistration {
41
+ name: string;
42
+ description?: string;
43
+ order?: number;
44
+ showInMenu?: boolean;
45
+ emoji?: string;
46
+ handler: (ctx: TelegramExtensionCommandContext) => Promise<void> | void;
47
+ }
48
+
49
+ interface RegisteredTelegramExtensionCommand {
50
+ name: string;
51
+ description?: string;
52
+ order: number;
53
+ showInMenu: boolean;
54
+ emoji?: string;
55
+ handler: TelegramExtensionCommandRegistration["handler"];
56
+ }
57
+
58
+ interface TelegramExtensionCommandRegistry {
59
+ commands: Map<string, RegisteredTelegramExtensionCommand>;
60
+ }
61
+
62
+ function getOrCreateTelegramCommandRegistry(): TelegramExtensionCommandRegistry {
63
+ const existing = (globalThis as Record<string, unknown>)[
64
+ TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY
65
+ ];
66
+ if (
67
+ existing &&
68
+ typeof existing === "object" &&
69
+ existing !== null &&
70
+ "commands" in existing &&
71
+ existing.commands instanceof Map
72
+ ) {
73
+ return existing as TelegramExtensionCommandRegistry;
74
+ }
75
+ const registry: TelegramExtensionCommandRegistry = { commands: new Map() };
76
+ (globalThis as Record<string, unknown>)[
77
+ TELEGRAM_EXTENSION_COMMAND_REGISTRY_KEY
78
+ ] = registry;
79
+ return registry;
80
+ }
81
+
82
+ export function normalizeTelegramExtensionCommandName(name: string): string {
83
+ return name.trim().replace(/^\/+/, "").toLowerCase();
84
+ }
85
+
86
+ export function isTelegramExtensionCommandName(name: string): boolean {
87
+ return TELEGRAM_BOT_COMMAND_NAME_PATTERN.test(name);
88
+ }
89
+
90
+ function normalizeTelegramExtensionCommandEmoji(
91
+ emoji: string | undefined,
92
+ ): string | undefined {
93
+ const normalized = emoji?.trim();
94
+ return normalized ? normalized : undefined;
95
+ }
96
+
97
+ export function registerTelegramCommand(
98
+ registration: TelegramExtensionCommandRegistration,
99
+ ): () => void {
100
+ const name = normalizeTelegramExtensionCommandName(registration.name);
101
+ const showInMenu = registration.showInMenu ?? false;
102
+ const emoji = normalizeTelegramExtensionCommandEmoji(registration.emoji);
103
+ if (!isTelegramExtensionCommandName(name)) {
104
+ throw new Error(`Invalid Telegram command name: ${registration.name}`);
105
+ }
106
+ if (showInMenu && !emoji) {
107
+ throw new Error(`Visible Telegram command requires emoji: ${name}`);
108
+ }
109
+ if (emoji && emoji.length > 8) {
110
+ throw new Error(`Telegram command emoji is too long: ${name}`);
111
+ }
112
+ if (isTelegramReservedCommandName(name)) {
113
+ throw new Error(
114
+ `Telegram command conflicts with built-in command: ${name}`,
115
+ );
116
+ }
117
+ const registry = getOrCreateTelegramCommandRegistry();
118
+ if (registry.commands.has(name)) {
119
+ throw new Error(`Telegram command is already registered: ${name}`);
120
+ }
121
+ const command: RegisteredTelegramExtensionCommand = {
122
+ name,
123
+ description: registration.description,
124
+ order: registration.order ?? 0,
125
+ showInMenu,
126
+ emoji,
127
+ handler: registration.handler,
128
+ };
129
+ registry.commands.set(name, command);
130
+ return () => {
131
+ if (registry.commands.get(name) === command) registry.commands.delete(name);
132
+ };
133
+ }
134
+
135
+ export function getTelegramExtensionCommands(): RegisteredTelegramExtensionCommand[] {
136
+ return Array.from(
137
+ getOrCreateTelegramCommandRegistry().commands.values(),
138
+ ).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
139
+ }
140
+
141
+ export function findTelegramExtensionCommand(
142
+ name: string | undefined,
143
+ ): RegisteredTelegramExtensionCommand | undefined {
144
+ if (!name) return undefined;
145
+ return getOrCreateTelegramCommandRegistry().commands.get(
146
+ normalizeTelegramExtensionCommandName(name),
147
+ );
148
+ }
149
+
150
+ export function clearTelegramExtensionCommands(): void {
151
+ getOrCreateTelegramCommandRegistry().commands.clear();
152
+ }
153
+
30
154
  export const TELEGRAM_COMMAND_EMOJI = {
31
155
  start: "🟢",
32
156
  status: "📊",
@@ -106,6 +230,22 @@ export const TELEGRAM_BUILTIN_BOT_COMMANDS: readonly TelegramBotCommandDefinitio
106
230
 
107
231
  export const TELEGRAM_BOT_COMMANDS = TELEGRAM_BUILTIN_BOT_COMMANDS;
108
232
 
233
+ function getVisibleTelegramExtensionBotCommands(): TelegramBotCommandDefinition[] {
234
+ return getTelegramExtensionCommands()
235
+ .filter((command) => command.showInMenu && command.description)
236
+ .map((command) => ({
237
+ command: command.name,
238
+ description: `${command.emoji} ${command.description ?? command.name}`,
239
+ }));
240
+ }
241
+
242
+ export function getTelegramReservedCommandNames(): string[] {
243
+ return [
244
+ ...TELEGRAM_RESERVED_COMMAND_NAMES,
245
+ ...getTelegramExtensionCommands().map((command) => command.name),
246
+ ];
247
+ }
248
+
109
249
  export interface TelegramBotCommandRegistrationDeps {
110
250
  setMyCommands: (
111
251
  commands: readonly TelegramBotCommandDefinition[],
@@ -115,7 +255,23 @@ export interface TelegramBotCommandRegistrationDeps {
115
255
  export async function registerTelegramBotCommands(
116
256
  deps: TelegramBotCommandRegistrationDeps,
117
257
  ): Promise<void> {
118
- await deps.setMyCommands(TELEGRAM_BOT_COMMANDS);
258
+ const extensionCommands = getVisibleTelegramExtensionBotCommands();
259
+ if (extensionCommands.length === 0) {
260
+ await deps.setMyCommands(TELEGRAM_BOT_COMMANDS);
261
+ return;
262
+ }
263
+ const compactCommandIndex = TELEGRAM_BOT_COMMANDS.findIndex(
264
+ (command) => command.command === "compact",
265
+ );
266
+ if (compactCommandIndex === -1) {
267
+ await deps.setMyCommands([...TELEGRAM_BOT_COMMANDS, ...extensionCommands]);
268
+ return;
269
+ }
270
+ await deps.setMyCommands([
271
+ ...TELEGRAM_BOT_COMMANDS.slice(0, compactCommandIndex + 1),
272
+ ...extensionCommands,
273
+ ...TELEGRAM_BOT_COMMANDS.slice(compactCommandIndex + 1),
274
+ ]);
119
275
  }
120
276
 
121
277
  export function createTelegramBotCommandRegistrar(
@@ -566,6 +722,11 @@ export interface TelegramCommandOrPromptRuntimeDeps<TMessage, TContext> {
566
722
  message: TMessage,
567
723
  ctx: TContext,
568
724
  ) => Promise<boolean>;
725
+ executeExtensionCommand?: (
726
+ command: ParsedTelegramCommand,
727
+ message: TMessage,
728
+ ctx: TContext,
729
+ ) => Promise<boolean>;
569
730
  expandPromptTemplateCommand?: (
570
731
  commandName: string,
571
732
  args: string,
@@ -650,15 +811,41 @@ function buildTelegramPromptTemplateMenuHtml(
650
811
  .join("\n");
651
812
  }
652
813
 
814
+ function buildTelegramExtensionCommandMenuLines(): string[] {
815
+ return getTelegramExtensionCommands()
816
+ .filter((command) => command.showInMenu)
817
+ .map((command) => {
818
+ const prefix = `${escapeTelegramCommandMenuHtml(command.emoji ?? "")} /${escapeTelegramCommandMenuHtml(command.name)}`;
819
+ if (!command.description) return prefix;
820
+ return `${prefix} — ${escapeTelegramCommandMenuHtml(command.description)}`;
821
+ });
822
+ }
823
+
824
+ function buildTelegramAppMenuIntroHtml(): string {
825
+ const extensionLines = buildTelegramExtensionCommandMenuLines();
826
+ if (extensionLines.length === 0) return TELEGRAM_APP_MENU_INTRO_HTML;
827
+ return [
828
+ "<b>π Telegram</b>",
829
+ "",
830
+ `${formatTelegramCommandEmojiPrefix("start")}/start — Open menu / Pair bridge`,
831
+ `${formatTelegramCommandEmojiPrefix("compact")}/compact — Compact current session`,
832
+ ...extensionLines,
833
+ `${formatTelegramCommandEmojiPrefix("next")}/next — Force next turn`,
834
+ `${formatTelegramCommandEmojiPrefix("continue")}/continue — Queue continue prompt`,
835
+ `${formatTelegramCommandEmojiPrefix("abort")}/abort — Abort π`,
836
+ `${formatTelegramCommandEmojiPrefix("stop")}/stop — Abort π & Clear queue`,
837
+ ].join("\n");
838
+ }
839
+
653
840
  export function buildTelegramAppMenuHtml(
654
841
  statusHtml: string,
655
842
  promptTemplates: readonly TelegramPromptTemplateMenuCommand[] = [],
656
843
  ): string {
844
+ const introHtml = buildTelegramAppMenuIntroHtml();
657
845
  const promptTemplateHtml =
658
846
  buildTelegramPromptTemplateMenuHtml(promptTemplates);
659
- if (!promptTemplateHtml)
660
- return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${statusHtml}`;
661
- return `${TELEGRAM_APP_MENU_INTRO_HTML}\n\n${promptTemplateHtml}\n\n${statusHtml}`;
847
+ if (!promptTemplateHtml) return `${introHtml}\n\n${statusHtml}`;
848
+ return `${introHtml}\n\n${promptTemplateHtml}\n\n${statusHtml}`;
662
849
  }
663
850
 
664
851
  export function createTelegramAppMenuHtmlBuilder<TContext>(deps: {
@@ -1133,6 +1320,14 @@ export function createTelegramCommandOrPromptRuntime<TMessage, TContext>(
1133
1320
  ctx,
1134
1321
  );
1135
1322
  if (handled) return;
1323
+ if (command && deps.executeExtensionCommand) {
1324
+ const handledByExtension = await deps.executeExtensionCommand(
1325
+ command,
1326
+ messages[0]!,
1327
+ ctx,
1328
+ );
1329
+ if (handledByExtension) return;
1330
+ }
1136
1331
  if (command?.name && deps.expandPromptTemplateCommand) {
1137
1332
  const expanded = deps.expandPromptTemplateCommand(
1138
1333
  command.name,
@@ -99,6 +99,7 @@ export function mapPiPromptTemplateNameToTelegramCommandName(
99
99
  export interface TelegramPromptTemplateCommandGetterDeps {
100
100
  getCommands: () => readonly PiSlashCommandInfo[];
101
101
  reservedCommandNames?: readonly string[];
102
+ getReservedCommandNames?: () => readonly string[];
102
103
  }
103
104
 
104
105
  export function getTelegramPromptTemplateCommands(
@@ -128,9 +129,14 @@ export function getTelegramPromptTemplateCommands(
128
129
  export function createTelegramPromptTemplateCommandGetter(
129
130
  deps: TelegramPromptTemplateCommandGetterDeps,
130
131
  ): () => TelegramPromptTemplateCommand[] {
131
- const reservedNames = new Set(deps.reservedCommandNames);
132
132
  return function getPromptTemplateCommands() {
133
- return getTelegramPromptTemplateCommands(deps.getCommands(), reservedNames);
133
+ return getTelegramPromptTemplateCommands(
134
+ deps.getCommands(),
135
+ new Set([
136
+ ...(deps.reservedCommandNames ?? []),
137
+ ...(deps.getReservedCommandNames?.() ?? []),
138
+ ]),
139
+ );
134
140
  };
135
141
  }
136
142
 
package/lib/routing.ts CHANGED
@@ -358,13 +358,12 @@ export function createTelegramInboundRouteRuntime<
358
358
  deps.queueMutationRuntime.append(continueTurn, ctx);
359
359
  deps.dispatchNextQueuedTelegramTurn(ctx);
360
360
  };
361
- const reservedCommandNames = new Set(
362
- Commands.TELEGRAM_RESERVED_COMMAND_NAMES,
363
- );
361
+ const reservedCommandNames = () =>
362
+ new Set(Commands.getTelegramReservedCommandNames());
364
363
  const getPromptTemplateCommands = () =>
365
364
  PromptTemplates.getTelegramPromptTemplateCommands(
366
365
  deps.getCommands(),
367
- reservedCommandNames,
366
+ reservedCommandNames(),
368
367
  );
369
368
  const commandHandler = Commands.createTelegramCommandHandlerTargetRuntime<
370
369
  TMessage,
@@ -434,6 +433,43 @@ export function createTelegramInboundRouteRuntime<
434
433
  >({
435
434
  extractRawText: Media.extractFirstTelegramMessageText,
436
435
  handleCommand: commandHandler,
436
+ executeExtensionCommand: async (command, message, ctx) => {
437
+ const extensionCommand = Commands.findTelegramExtensionCommand(
438
+ command.name,
439
+ );
440
+ if (!extensionCommand) return false;
441
+ try {
442
+ await extensionCommand.handler({
443
+ name: command.name,
444
+ args: command.args,
445
+ reply: (text) =>
446
+ deps
447
+ .sendTextReply(message.chat.id, message.message_id, text)
448
+ .then(() => {}),
449
+ enqueuePrompt: (prompt) =>
450
+ promptEnqueue(
451
+ [
452
+ {
453
+ ...message,
454
+ text: prompt,
455
+ caption: undefined,
456
+ } as TMessage,
457
+ ],
458
+ ctx,
459
+ ),
460
+ });
461
+ } catch (error) {
462
+ deps.recordRuntimeEvent?.("telegram-command", error, {
463
+ command: command.name,
464
+ });
465
+ await deps.sendTextReply(
466
+ message.chat.id,
467
+ message.message_id,
468
+ "Command failed.",
469
+ );
470
+ }
471
+ return true;
472
+ },
437
473
  expandPromptTemplateCommand: (commandName, args) =>
438
474
  PromptTemplates.expandTelegramPromptTemplateCommand(
439
475
  commandName,
package/lib/status.ts CHANGED
@@ -529,15 +529,15 @@ export function buildTelegramStatusBarText(
529
529
  if (state.error) {
530
530
  return `${label} ${theme.fg("error", "error")} ${theme.fg("muted", state.error)}`;
531
531
  }
532
- if (!state.hasBotToken)
533
- return `${label} ${theme.fg("muted", "not configured")}`;
534
- if (!state.pollingActive)
535
- return `${label} ${theme.fg("muted", "disconnected")}`;
536
- if (!state.paired)
537
- return `${label} ${theme.fg("warning", "awaiting pairing")}`;
538
532
  const queued = state.queuedStatus
539
533
  ? theme.fg("success", state.queuedStatus)
540
534
  : "";
535
+ if (!state.hasBotToken)
536
+ return `${label} ${theme.fg("muted", "not configured")}${queued}`;
537
+ if (!state.pollingActive)
538
+ return `${label} ${theme.fg("muted", "disconnected")}${queued}`;
539
+ if (!state.paired)
540
+ return `${label} ${theme.fg("warning", "awaiting pairing")}${queued}`;
541
541
  if (state.compactionInProgress) {
542
542
  return `${label} ${theme.fg("warning", "compacting")}${queued}`;
543
543
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -49,6 +49,7 @@
49
49
  "./inbound": "./api/inbound.ts",
50
50
  "./outbound": "./api/outbound.ts",
51
51
  "./updates": "./api/updates.ts",
52
+ "./commands": "./api/commands.ts",
52
53
  "./sections": "./api/sections.ts",
53
54
  "./status": "./api/status.ts",
54
55
  "./voice": "./api/voice.ts",