mirror of
https://github.com/wassname/pi-telegram.git
synced 2026-06-27 16:46:21 +08:00
Register pi commands in Telegram menu
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
## Current
|
||||
|
||||
- `[Runtime]` Fixed Telegram slash-command routing so `/stop`, `/status`, `/model`, and other local commands receive the real Telegram message and pi context instead of the wrong argument positions. Added stale-abort recovery so if pi is already idle but the bridge still thinks an aborted Telegram turn is active, the next Telegram message clears the stale local state and dispatch resumes. Impact: Telegram no longer gets permanently wedged while local `!` commands still work.
|
||||
- `[Command Menu]` `/start` now refreshes Telegram's bot command menu with bridge-local controls plus every Telegram-valid pi prompt, skill, and extension command from `pi.getCommands()`, including aliases such as `/p` when available. Invalid Bot API names are filtered and the menu is capped at Telegram's 100-command limit. Impact: Telegram's command picker better matches commands that work from the DM.
|
||||
- `[Trace + Shell Output]` Compact trace mode now marks shortened thinking/tool blocks explicitly with a “use /trace for full” notice, full mode keeps the complete final trace, and direct `!` shell replies are delivered through chunked Telegram-safe markdown instead of silently slicing off the tail. Impact: trace and shell output truncation is visible instead of hidden, and verbose output remains available.
|
||||
|
||||
- `[Security]` Removed auto-pair-on-first-DM behavior. The bot now requires `allowedUserId` to be set before polling starts. Configure it via `TELEGRAM_ALLOWED_USER_ID` env var or the updated `/telegram-setup` prompt (which now asks for a numeric user ID after the bot token). The env var takes precedence over the saved config on every session start. Denied senders get an auth error reply; their numeric ID is also logged to the pi TUI as a warning. Breaking change: fresh installs require explicit configuration; existing installs with `allowedUserId` already in `telegram.json` continue to work unchanged.
|
||||
|
||||
@@ -119,6 +119,7 @@ Chat with your bot in Telegram DMs.
|
||||
|
||||
Additional fork-specific controls:
|
||||
|
||||
- `/start` shows help and refreshes Telegram's bot command menu with local bridge controls plus Telegram-valid pi prompt, skill, and extension commands such as `/p`
|
||||
- `/status` now has a richer view with inline buttons for model and thinking controls, and joins the high-priority control queue when pi is busy
|
||||
- `/model` opens the interactive model selector, applies idle selections immediately, joins the high-priority control queue when pi is busy, and can restart the active Telegram-owned run on the newly selected model, waiting for the current tool call to finish when needed
|
||||
- `/compact` starts session compaction when pi and the Telegram queue are idle
|
||||
@@ -209,6 +210,8 @@ Compact trace mode marks shortened thinking/tool blocks explicitly instead of si
|
||||
|
||||
Direct `!` shell command replies are delivered in full across Telegram-safe chunks instead of being cut to the first screenful.
|
||||
|
||||
Telegram's bot command menu is refreshed by `/start`. The bridge publishes its local controls first, then any pi prompt, skill, or extension commands whose names are accepted by Telegram's Bot API.
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one pi session should be connected to the bot at a time
|
||||
|
||||
@@ -135,6 +135,7 @@ The bridge exposes Telegram-side session controls in addition to regular chat fo
|
||||
|
||||
Current operator controls include:
|
||||
|
||||
- `/start` for help and Telegram bot command-menu refresh. The menu publishes bridge-local controls first, then Telegram-valid pi commands from `pi.getCommands()`, including prompt, skill, and extension commands such as `/p` when pi exposes them.
|
||||
- `/status` for model, usage, cost, and context visibility, queued as a high-priority control item when needed
|
||||
- Inline status buttons for model and thinking adjustments, applying idle selections immediately while still respecting busy-run restart rules
|
||||
- `/model` for interactive model selection, queued as a high-priority control item when needed and supporting in-flight restart of the active Telegram-owned run on a newly selected model
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Telegram Bot Command Menu Registration
|
||||
|
||||
## Goal
|
||||
Make Telegram's bot command menu match the commands that are useful from the DM bridge, including pi prompt and skill commands such as `/p`, not just bridge-local and extension commands.
|
||||
|
||||
## Scope
|
||||
In: bot command menu construction, command registration call, docs, changelog, focused regression tests.
|
||||
Out: changing Telegram slash-command execution semantics or making Telegram accept command names the Bot API rejects.
|
||||
|
||||
## Requirements
|
||||
- R1: The Telegram bot command menu includes bridge-local commands and every Telegram-valid command returned by `pi.getCommands()`, including `source: "prompt"` and `source: "skill"`. Done means: a test with prompt command `p` produces a `setMyCommands` payload containing `p`. VERIFY: `node --experimental-strip-types --test tests/registration.test.ts`.
|
||||
- R2: The command menu registration remains Bot API-compatible. Done means: invalid command names are filtered, duplicates are de-duped with bridge-local commands first, and the final list is capped at Telegram's 100-command limit. VERIFY: a pure builder test asserts invalid names and overflow commands are excluded.
|
||||
- R3: User-facing docs mention that `/start` refreshes the Telegram menu with bridge-local commands plus Telegram-valid pi prompt/skill/extension commands. VERIFY: docs grep shows command-menu behavior in README, architecture, and changelog.
|
||||
|
||||
## Tasks
|
||||
- [x] T1 (R1, R2): Extract/test pure Telegram bot-command menu builder.
|
||||
- steps: move local command definitions into a reusable helper, include all valid `pi.getCommands()` entries, de-dupe, cap at 100.
|
||||
- verify: `node --experimental-strip-types --test tests/registration.test.ts`.
|
||||
- success: test payload includes `p`, `skill_cmd`, excludes invalid `bad-name`/`review:1`, and has length 100.
|
||||
- likely_fail: code still filters to extension only; test shows `p` missing.
|
||||
- sneaky_fail: registration includes invalid names or too many commands; test checks both.
|
||||
- UAT: "when I send `/start`, Telegram's command menu offers `/p` if pi exposes it as a valid command."
|
||||
- [x] T2 (R3): Update docs and changelog.
|
||||
- steps: update README usage/streaming area, architecture controls, changelog current entries.
|
||||
- verify: `rg -n "bot command|/start|/p|setMyCommands|prompt" README.md docs/architecture.md CHANGELOG.md`.
|
||||
- success: docs mention `/start` refresh and prompt/skill/extension publication.
|
||||
- likely_fail: docs only mention local commands; grep misses prompt/skill command menu text.
|
||||
- sneaky_fail: docs imply invalid command names can appear; wording says Telegram-valid commands only.
|
||||
|
||||
## Context
|
||||
- Telegram Bot API command names must match lowercase/digit/underscore and max 32 characters.
|
||||
- Telegram supports at most 100 commands in the bot command menu.
|
||||
- `pi.getCommands()` returns extension, prompt, and skill commands; built-in interactive commands are not included.
|
||||
- The bridge handles local commands in Telegram before queueing a pi turn; other slash commands pass through to pi.
|
||||
|
||||
## Log
|
||||
- `pi.getCommands()` docs in `node_modules/@mariozechner/pi-coding-agent/docs/extensions.md` state it includes extension, prompt, and skill commands, but not built-in interactive commands.
|
||||
- `node --experimental-strip-types --test tests/registration.test.ts`: 8 tests passed, including the `/p`-style command and Bot API cap cases.
|
||||
- `npm test`: 142 tests passed.
|
||||
- Docs grep verified `/start` command-menu refresh and prompt/skill/extension publication in README, architecture, changelog, and this spec.
|
||||
|
||||
## TODO
|
||||
- None.
|
||||
|
||||
## Errors
|
||||
| Task | Error | Resolution |
|
||||
|------|-------|------------|
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
type TelegramQueueItem,
|
||||
} from "./lib/queue.ts";
|
||||
import {
|
||||
buildTelegramBotCommands,
|
||||
registerTelegramAttachmentTool,
|
||||
registerTelegramCommands,
|
||||
registerTelegramLifecycleHooks,
|
||||
@@ -255,11 +256,6 @@ interface TelegramSentMessage {
|
||||
message_id: number;
|
||||
}
|
||||
|
||||
interface TelegramBotCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// --- Extension State Types ---
|
||||
|
||||
interface DownloadedTelegramFile {
|
||||
@@ -1051,27 +1047,7 @@ export default function (pi: ExtensionAPI) {
|
||||
}
|
||||
|
||||
async function registerTelegramBotCommands(): Promise<void> {
|
||||
const localCommands: TelegramBotCommand[] = [
|
||||
{ command: "start", description: "Show help" },
|
||||
{ command: "status", description: "Show model, usage, cost, and context status" },
|
||||
{ command: "trace", description: "Cycle display mode: text / compact / full" },
|
||||
{ command: "model", description: "Open the interactive model selector" },
|
||||
{ command: "compact", description: "Compact the current pi session" },
|
||||
{ command: "stop", description: "Abort the current pi task" },
|
||||
];
|
||||
const localNames = new Set(localCommands.map((c) => c.command));
|
||||
const telegramCommandNamePattern = /^[a-z0-9_]{1,32}$/;
|
||||
const extensionCommands: TelegramBotCommand[] = pi.getCommands()
|
||||
.filter((c: { name: string; description?: string; source?: string }) =>
|
||||
c.source === "extension" &&
|
||||
!localNames.has(c.name) &&
|
||||
telegramCommandNamePattern.test(c.name),
|
||||
)
|
||||
.map((c: { name: string; description?: string }) => ({
|
||||
command: c.name,
|
||||
description: c.description ?? c.name,
|
||||
}));
|
||||
const commands = [...localCommands, ...extensionCommands];
|
||||
const commands = buildTelegramBotCommands(pi.getCommands());
|
||||
await callTelegramApi<boolean>("setMyCommands", { commands });
|
||||
}
|
||||
|
||||
@@ -1798,7 +1774,7 @@ export default function (pi: ExtensionAPI) {
|
||||
ctx: ExtensionContext,
|
||||
): Promise<void> {
|
||||
let helpText =
|
||||
"Send me a message and I will forward it to pi.\n\nLocal: /status, /trace, /model, /compact, /stop, /quit\nOther /commands and ! shell commands pass through to pi directly.";
|
||||
"Send me a message and I will forward it to pi.\n\nLocal: /status, /trace, /model, /compact, /stop, /quit\n/start refreshes Telegram's command menu with local controls plus valid pi prompt, skill, and extension commands.\nOther /commands and ! shell commands pass through to pi directly.";
|
||||
|
||||
if (commandName === "start") {
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ExtensionAPI,
|
||||
ExtensionCommandContext,
|
||||
ExtensionContext,
|
||||
SlashCommandInfo,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
|
||||
import { queueTelegramAttachments } from "./attachments.ts";
|
||||
@@ -65,6 +66,52 @@ export function registerTelegramAttachmentTool(
|
||||
|
||||
// --- Command Registration ---
|
||||
|
||||
export interface TelegramBotCommand {
|
||||
command: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const TELEGRAM_BOT_COMMAND_LIMIT = 100;
|
||||
|
||||
const telegramCommandNamePattern = /^[a-z0-9_]{1,32}$/;
|
||||
|
||||
const bridgeLocalBotCommands: TelegramBotCommand[] = [
|
||||
{ command: "start", description: "Show help and refresh this menu" },
|
||||
{ command: "help", description: "Show help" },
|
||||
{ command: "status", description: "Show model, usage, cost, and context status" },
|
||||
{ command: "trace", description: "Cycle display mode: text / compact / full" },
|
||||
{ command: "model", description: "Open the interactive model selector" },
|
||||
{ command: "compact", description: "Compact the current pi session" },
|
||||
{ command: "stop", description: "Abort the current pi task" },
|
||||
{ command: "quit", description: "Stop the Telegram bridge in this session" },
|
||||
{ command: "exit", description: "Stop the Telegram bridge in this session" },
|
||||
];
|
||||
|
||||
export function buildTelegramBotCommands(
|
||||
piCommands: Pick<SlashCommandInfo, "name" | "description">[],
|
||||
): TelegramBotCommand[] {
|
||||
const commands: TelegramBotCommand[] = [];
|
||||
const names = new Set<string>();
|
||||
const addCommand = (command: TelegramBotCommand) => {
|
||||
if (names.has(command.command)) return;
|
||||
if (!telegramCommandNamePattern.test(command.command)) return;
|
||||
names.add(command.command);
|
||||
commands.push(command);
|
||||
};
|
||||
|
||||
for (const command of bridgeLocalBotCommands) {
|
||||
addCommand(command);
|
||||
}
|
||||
for (const command of piCommands) {
|
||||
addCommand({
|
||||
command: command.name,
|
||||
description: command.description ?? command.name,
|
||||
});
|
||||
}
|
||||
|
||||
return commands.slice(0, TELEGRAM_BOT_COMMAND_LIMIT);
|
||||
}
|
||||
|
||||
export interface TelegramCommandRegistrationDeps {
|
||||
promptForConfig: (ctx: ExtensionCommandContext) => Promise<void>;
|
||||
getStatusLines: () => string[];
|
||||
|
||||
@@ -8,9 +8,11 @@ import test from "node:test";
|
||||
|
||||
import telegramExtension from "../index.ts";
|
||||
import {
|
||||
buildTelegramBotCommands,
|
||||
registerTelegramAttachmentTool,
|
||||
registerTelegramCommands,
|
||||
registerTelegramLifecycleHooks,
|
||||
TELEGRAM_BOT_COMMAND_LIMIT,
|
||||
} from "../lib/registration.ts";
|
||||
|
||||
function createRegistrationApiHarness() {
|
||||
@@ -137,6 +139,61 @@ test("Registration connect and disconnect commands reload config and control pol
|
||||
]);
|
||||
});
|
||||
|
||||
test("Telegram bot command menu includes valid pi prompt, skill, and extension commands", () => {
|
||||
const commands = buildTelegramBotCommands([
|
||||
{ name: "p", description: "Run prompt template" },
|
||||
{ name: "skill_cmd", description: "Run skill command" },
|
||||
{ name: "ext_cmd", description: "Run extension command" },
|
||||
{ name: "status", description: "Duplicate should not replace local status" },
|
||||
{ name: "bad-name", description: "Telegram rejects hyphenated names" },
|
||||
{ name: "review:1", description: "Telegram rejects colon suffixed names" },
|
||||
{ name: "UPPER", description: "Telegram rejects uppercase names" },
|
||||
{ name: "x".repeat(33), description: "Telegram rejects long names" },
|
||||
] as never);
|
||||
|
||||
assert.deepEqual(
|
||||
commands.map((command) => command.command).slice(0, 12),
|
||||
[
|
||||
"start",
|
||||
"help",
|
||||
"status",
|
||||
"trace",
|
||||
"model",
|
||||
"compact",
|
||||
"stop",
|
||||
"quit",
|
||||
"exit",
|
||||
"p",
|
||||
"skill_cmd",
|
||||
"ext_cmd",
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
commands.find((command) => command.command === "status")?.description,
|
||||
"Show model, usage, cost, and context status",
|
||||
);
|
||||
assert.equal(commands.some((command) => command.command === "bad-name"), false);
|
||||
assert.equal(commands.some((command) => command.command === "review:1"), false);
|
||||
assert.equal(commands.some((command) => command.command === "UPPER"), false);
|
||||
assert.equal(commands.some((command) => command.command.length > 32), false);
|
||||
});
|
||||
|
||||
test("Telegram bot command menu is capped at the Bot API command limit", () => {
|
||||
const piCommands = Array.from({ length: TELEGRAM_BOT_COMMAND_LIMIT + 20 }, (_, index) => ({
|
||||
name: `cmd_${index}`,
|
||||
description: `Command ${index}`,
|
||||
}));
|
||||
const commands = buildTelegramBotCommands(piCommands as never);
|
||||
|
||||
assert.equal(commands.length, TELEGRAM_BOT_COMMAND_LIMIT);
|
||||
assert.equal(commands.at(0)?.command, "start");
|
||||
assert.equal(commands.some((command) => command.command === "cmd_0"), true);
|
||||
assert.equal(
|
||||
commands.some((command) => command.command === `cmd_${TELEGRAM_BOT_COMMAND_LIMIT}`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("Registration lifecycle hooks are registered and delegate to the provided handlers", async () => {
|
||||
const harness = createRegistrationApiHarness();
|
||||
const events: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user