Compare commits

...

8 Commits

Author SHA1 Message Date
Peter Steinberger
185ffca274 fix: filter telegram native command names (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
c2940adc80 fix: register plugin commands natively (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
29043209c9 fix: refresh reserved plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Peter Steinberger
bfc0fb742e fix: harden plugin commands (#1558) (thanks @Glucksberg) 2026-01-24 06:23:39 +00:00
Glucksberg
051c92651f fix: address code review findings for plugin commands
- Add registry lock during command execution to prevent race conditions
- Add input sanitization for command arguments (defense in depth)
- Validate handler is a function during registration
- Remove redundant case-insensitive regex flag
- Add success logging for command execution
- Simplify handler return type (always returns result now)
- Remove dead code branch in commands-plugin.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
b16576f298 fix: clear plugin commands on reload to prevent duplicates
Add clearPluginCommands() call in loadClawdbotPlugins() to ensure
previously registered commands are cleaned up before reloading plugins.
This prevents command conflicts during hot-reload scenarios.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
691adccf32 fix: address code review findings for plugin command API
Blockers fixed:
- Fix documentation: requireAuth defaults to true (not false)
- Add command name validation (must start with letter, alphanumeric only)
- Add reserved commands list to prevent shadowing built-in commands
- Emit diagnostic errors for invalid/duplicate command registration

Other improvements:
- Return user-friendly message for unauthorized commands (instead of silence)
- Sanitize error messages to avoid leaking internal details
- Document acceptsArgs behavior when arguments are provided
- Add notes about reserved commands and validation rules to docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
Glucksberg
420af3285f feat: add plugin command API for LLM-free auto-reply commands
This adds a new `api.registerCommand()` method to the plugin API, allowing
plugins to register slash commands that execute without invoking the AI agent.

Features:
- Plugin commands are processed before built-in commands and the agent
- Commands can optionally require authorization
- Commands can accept arguments
- Async handlers are supported

Use case: plugins can implement toggle commands (like /tts_on, /tts_off)
that respond immediately without consuming LLM API calls.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:23:39 +00:00
19 changed files with 739 additions and 5 deletions

View File

@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). - Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07. - Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. - CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. - CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. - CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.

View File

@ -62,6 +62,7 @@ Plugins can register:
- Background services - Background services
- Optional config validation - Optional config validation
- **Skills** (by listing `skills` directories in the plugin manifest) - **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Plugins run **inprocess** with the Gateway, so treat them as trusted code. Plugins run **inprocess** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
@ -494,6 +495,66 @@ export default function (api) {
} }
``` ```
### Register auto-reply commands
Plugins can register custom slash commands that execute **without invoking the
AI agent**. This is useful for toggle commands, status checks, or quick actions
that don't need LLM processing.
```ts
export default function (api) {
api.registerCommand({
name: "mystatus",
description: "Show plugin status",
handler: (ctx) => ({
text: `Plugin is running! Channel: ${ctx.channel}`,
}),
});
}
```
Command handler context:
- `senderId`: The sender's ID (if available)
- `channel`: The channel where the command was sent
- `isAuthorizedSender`: Whether the sender is an authorized user
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
- `commandBody`: The full command text
- `config`: The current Clawdbot config
Command options:
- `name`: Command name (without the leading `/`)
- `description`: Help text shown in command lists
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
- `requireAuth`: Whether to require authorized sender (default: true)
- `handler`: Function that returns `{ text: string }` (can be async)
Example with authorization and arguments:
```ts
api.registerCommand({
name: "setmode",
description: "Set plugin mode",
acceptsArgs: true,
requireAuth: true,
handler: async (ctx) => {
const mode = ctx.args?.trim() || "default";
await saveMode(mode);
return { text: `Mode set to: ${mode}` };
},
});
```
Notes:
- Plugin commands are processed **before** built-in commands and the AI agent
- Commands are registered globally and work across all channels
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegrams native command list.
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
- Duplicate command registration across plugins will fail with a diagnostic error
### Register background services ### Register background services
```ts ```ts

View File

@ -8,6 +8,7 @@ import {
listChatCommandsForConfig, listChatCommandsForConfig,
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
normalizeCommandBody, normalizeCommandBody,
parseCommandArgs, parseCommandArgs,
resolveCommandArgMenu, resolveCommandArgMenu,
@ -15,15 +16,18 @@ import {
shouldHandleTextCommands, shouldHandleTextCommands,
} from "./commands-registry.js"; } from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js"; import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { setActivePluginRegistry } from "../plugins/runtime.js"; import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => { beforeEach(() => {
setActivePluginRegistry(createTestRegistry([])); setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
}); });
afterEach(() => { afterEach(() => {
setActivePluginRegistry(createTestRegistry([])); setActivePluginRegistry(createTestRegistry([]));
clearPluginCommands();
}); });
describe("commands registry", () => { describe("commands registry", () => {
@ -42,6 +46,20 @@ describe("commands registry", () => {
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy(); expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
}); });
it("normalizes telegram native command specs", () => {
const specs = [
{ name: "OK", description: "Ok", acceptsArgs: false },
{ name: "bad-name", description: "Bad", acceptsArgs: false },
{ name: "fine_name", description: "Fine", acceptsArgs: false },
{ name: "ok", description: "Dup", acceptsArgs: false },
];
const normalized = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs,
});
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
});
it("filters commands based on config flags", () => { it("filters commands based on config flags", () => {
const disabled = listChatCommandsForConfig({ const disabled = listChatCommandsForConfig({
commands: { config: false, debug: false }, commands: { config: false, debug: false },
@ -85,6 +103,19 @@ describe("commands registry", () => {
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy(); expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
}); });
it("includes plugin commands in native specs", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const native = listNativeCommandSpecsForConfig(
{ commands: { config: false, debug: false, native: true } },
{ skillCommands: [] },
);
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
});
it("detects known text commands", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/commands")).toBe(true); expect(detection.exact.has("/commands")).toBe(true);

View File

@ -1,8 +1,13 @@
import type { ClawdbotConfig } from "../config/types.js"; import type { ClawdbotConfig } from "../config/types.js";
import type { SkillCommandSpec } from "../agents/skills.js"; import type { SkillCommandSpec } from "../agents/skills.js";
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js"; import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
import { getPluginCommandSpecs } from "../plugins/commands.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
} from "../config/telegram-custom-commands.js";
import type { import type {
ChatCommandDefinition, ChatCommandDefinition,
CommandArgChoiceContext, CommandArgChoiceContext,
@ -108,7 +113,7 @@ export function listChatCommandsForConfig(
export function listNativeCommandSpecs(params?: { export function listNativeCommandSpecs(params?: {
skillCommands?: SkillCommandSpec[]; skillCommands?: SkillCommandSpec[];
}): NativeCommandSpec[] { }): NativeCommandSpec[] {
return listChatCommands({ skillCommands: params?.skillCommands }) const base = listChatCommands({ skillCommands: params?.skillCommands })
.filter((command) => command.scope !== "text" && command.nativeName) .filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({ .map((command) => ({
name: command.nativeName ?? command.key, name: command.nativeName ?? command.key,
@ -116,13 +121,18 @@ export function listNativeCommandSpecs(params?: {
acceptsArgs: Boolean(command.acceptsArgs), acceptsArgs: Boolean(command.acceptsArgs),
args: command.args, args: command.args,
})); }));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
} }
export function listNativeCommandSpecsForConfig( export function listNativeCommandSpecsForConfig(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
params?: { skillCommands?: SkillCommandSpec[] }, params?: { skillCommands?: SkillCommandSpec[] },
): NativeCommandSpec[] { ): NativeCommandSpec[] {
return listChatCommandsForConfig(cfg, params) const base = listChatCommandsForConfig(cfg, params)
.filter((command) => command.scope !== "text" && command.nativeName) .filter((command) => command.scope !== "text" && command.nativeName)
.map((command) => ({ .map((command) => ({
name: command.nativeName ?? command.key, name: command.nativeName ?? command.key,
@ -130,6 +140,42 @@ export function listNativeCommandSpecsForConfig(
acceptsArgs: Boolean(command.acceptsArgs), acceptsArgs: Boolean(command.acceptsArgs),
args: command.args, args: command.args,
})); }));
const pluginSpecs = getPluginCommandSpecs();
if (pluginSpecs.length === 0) return base;
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
return extras.length > 0 ? [...base, ...extras] : base;
}
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
const trimmed = name.trim();
if (!trimmed) return null;
if (surface === "telegram") {
const normalized = normalizeTelegramCommandName(trimmed);
if (!normalized) return null;
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
return normalized;
}
return trimmed;
}
export function normalizeNativeCommandSpecsForSurface(params: {
surface: string;
specs: NativeCommandSpec[];
}): NativeCommandSpec[] {
const surface = params.surface.toLowerCase();
if (!surface) return params.specs;
const normalized: NativeCommandSpec[] = [];
const seen = new Set<string>();
for (const spec of params.specs) {
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
if (!normalizedName) continue;
const key = normalizedName.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
}
return normalized;
} }
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined { export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {

View File

@ -24,6 +24,7 @@ import {
handleStopCommand, handleStopCommand,
handleUsageCommand, handleUsageCommand,
} from "./commands-session.js"; } from "./commands-session.js";
import { handlePluginCommand } from "./commands-plugin.js";
import type { import type {
CommandHandler, CommandHandler,
CommandHandlerResult, CommandHandlerResult,
@ -31,6 +32,8 @@ import type {
} from "./commands-types.js"; } from "./commands-types.js";
const HANDLERS: CommandHandler[] = [ const HANDLERS: CommandHandler[] = [
// Plugin commands are processed first, before built-in commands
handlePluginCommand,
handleBashCommand, handleBashCommand,
handleActivationCommand, handleActivationCommand,
handleSendPolicyCommand, handleSendPolicyCommand,

View File

@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
import type { HandleCommandsParams } from "./commands-types.js";
import { handlePluginCommand } from "./commands-plugin.js";
describe("handlePluginCommand", () => {
beforeEach(() => {
clearPluginCommands();
});
it("skips plugin commands when text commands are disabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, false);
expect(result).toBeNull();
});
it("executes plugin commands when text commands are enabled", async () => {
registerPluginCommand("plugin-core", {
name: "ping",
description: "Ping",
handler: () => ({ text: "pong" }),
});
const params = {
command: {
commandBodyNormalized: "/ping",
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
},
cfg: {} as ClawdbotConfig,
} as HandleCommandsParams;
const result = await handlePluginCommand(params, true);
expect(result?.reply?.text).toBe("pong");
});
});

View File

@ -0,0 +1,42 @@
/**
* Plugin Command Handler
*
* Handles commands registered by plugins, bypassing the LLM agent.
* This handler is called before built-in command handlers.
*/
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
/**
* Handle plugin-registered commands.
* Returns a result if a plugin command was matched and executed,
* or null to continue to the next handler.
*/
export const handlePluginCommand: CommandHandler = async (
params,
allowTextCommands,
): Promise<CommandHandlerResult | null> => {
if (!allowTextCommands) return null;
const { command, cfg } = params;
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
if (!match) return null;
// Execute the plugin command (always returns a result)
const result = await executePluginCommand({
command: match.command,
args: match.args,
senderId: command.senderId,
channel: command.channel,
isAuthorizedSender: command.isAuthorizedSender,
commandBody: command.commandBodyNormalized,
config: cfg,
});
return {
shouldContinue: false,
reply: { text: result.text },
};
};

View File

@ -4,9 +4,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { normalizeTestText } from "../../test/helpers/normalize-text.js"; import { normalizeTestText } from "../../test/helpers/normalize-text.js";
import { withTempHome } from "../../test/helpers/temp-home.js"; import { withTempHome } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js"; import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
afterEach(() => { afterEach(() => {
clearPluginCommands();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -423,6 +425,19 @@ describe("buildCommandsMessage", () => {
); );
expect(text).toContain("/demo_skill - Demo skill"); expect(text).toContain("/demo_skill - Demo skill");
}); });
it("includes plugin commands when registered", () => {
registerPluginCommand("plugin-core", {
name: "plugstatus",
description: "Plugin status",
handler: () => ({ text: "ok" }),
});
const text = buildCommandsMessage({
commands: { config: false, debug: false },
} as ClawdbotConfig);
expect(text).toContain("🔌 Plugin commands");
expect(text).toContain("/plugstatus - Plugin status");
});
}); });
describe("buildHelpMessage", () => { describe("buildHelpMessage", () => {

View File

@ -22,6 +22,7 @@ import {
} from "../utils/usage-format.js"; } from "../utils/usage-format.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js"; import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
import { listPluginCommands } from "../plugins/commands.js";
import type { SkillCommandSpec } from "../agents/skills.js"; import type { SkillCommandSpec } from "../agents/skills.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js"; import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
import type { MediaUnderstandingDecision } from "../media-understanding/types.js"; import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
@ -442,5 +443,12 @@ export function buildCommandsMessage(
const scopeLabel = command.scope === "text" ? " (text-only)" : ""; const scopeLabel = command.scope === "text" ? " (text-only)" : "";
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`); lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
} }
const pluginCommands = listPluginCommands();
if (pluginCommands.length > 0) {
lines.push("🔌 Plugin commands");
for (const command of pluginCommands) {
lines.push(`/${command.name} - ${command.description}`);
}
}
return lines.join("\n"); return lines.join("\n");
} }

View File

@ -12,6 +12,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
httpHandlers: [], httpHandlers: [],
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
commands: [],
diagnostics: [], diagnostics: [],
}; };
const merged = { ...base, ...overrides }; const merged = { ...base, ...overrides };

View File

@ -140,6 +140,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
httpHandlers: [], httpHandlers: [],
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
commands: [],
diagnostics: [], diagnostics: [],
}); });

View File

@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import {
clearPluginCommands,
executePluginCommand,
matchPluginCommand,
registerPluginCommand,
validateCommandName,
} from "./commands.js";
describe("validateCommandName", () => {
it("rejects reserved aliases from built-in commands", () => {
const error = validateCommandName("id");
expect(error).toContain("reserved");
});
});
describe("plugin command registry", () => {
beforeEach(() => {
clearPluginCommands();
});
it("normalizes command names for registration and matching", () => {
const result = registerPluginCommand("plugin-core", {
name: " ping ",
description: "Ping",
handler: () => ({ text: "pong" }),
});
expect(result.ok).toBe(true);
const match = matchPluginCommand("/ping");
expect(match?.command.name).toBe("ping");
});
it("blocks registration while a command is executing", async () => {
let nestedResult: { ok: boolean; error?: string } | undefined;
registerPluginCommand("plugin-core", {
name: "outer",
description: "Outer",
handler: () => {
nestedResult = registerPluginCommand("plugin-inner", {
name: "inner",
description: "Inner",
handler: () => ({ text: "ok" }),
});
return { text: "done" };
},
});
await executePluginCommand({
command: matchPluginCommand("/outer")!.command,
senderId: "user-1",
channel: "test",
isAuthorizedSender: true,
commandBody: "/outer",
config: {} as ClawdbotConfig,
});
expect(nestedResult?.ok).toBe(false);
expect(nestedResult?.error).toContain("processing is in progress");
});
});

281
src/plugins/commands.ts Normal file
View File

@ -0,0 +1,281 @@
/**
* Plugin Command Registry
*
* Manages commands registered by plugins that bypass the LLM agent.
* These commands are processed before built-in commands and before agent invocation.
*/
import type { ClawdbotConfig } from "../config/config.js";
import { listChatCommands } from "../auto-reply/commands-registry.js";
import type { ClawdbotPluginCommandDefinition, PluginCommandContext } from "./types.js";
import { logVerbose } from "../globals.js";
type RegisteredPluginCommand = ClawdbotPluginCommandDefinition & {
pluginId: string;
};
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
// Lock counter to prevent modifications during command execution
let registryLockCount = 0;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
function getReservedCommands(): Set<string> {
const reserved = new Set<string>();
for (const command of listChatCommands()) {
if (command.nativeName) {
const normalized = command.nativeName.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
for (const alias of command.textAliases ?? []) {
const trimmed = alias.trim();
if (!trimmed) continue;
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
const normalized = withoutSlash.trim().toLowerCase();
if (normalized) reserved.add(normalized);
}
}
return reserved;
}
/**
* Validate a command name.
* Returns an error message if invalid, or null if valid.
*/
export function validateCommandName(name: string): string | null {
const trimmed = name.trim().toLowerCase();
if (!trimmed) {
return "Command name cannot be empty";
}
// Must start with a letter, contain only letters, numbers, hyphens, underscores
// Note: trimmed is already lowercased, so no need for /i flag
if (!/^[a-z][a-z0-9_-]*$/.test(trimmed)) {
return "Command name must start with a letter and contain only letters, numbers, hyphens, and underscores";
}
// Check reserved commands
if (getReservedCommands().has(trimmed)) {
return `Command name "${trimmed}" is reserved by a built-in command`;
}
return null;
}
export type CommandRegistrationResult = {
ok: boolean;
error?: string;
};
/**
* Register a plugin command.
* Returns an error if the command name is invalid or reserved.
*/
export function registerPluginCommand(
pluginId: string,
command: ClawdbotPluginCommandDefinition,
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLockCount > 0) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
// Validate handler is a function
if (typeof command.handler !== "function") {
return { ok: false, error: "Command handler must be a function" };
}
const validationError = validateCommandName(command.name);
if (validationError) {
return { ok: false, error: validationError };
}
const normalizedName = command.name.trim();
const key = `/${normalizedName.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
const existing = pluginCommands.get(key)!;
return {
ok: false,
error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`,
};
}
pluginCommands.set(key, { ...command, name: normalizedName, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };
}
/**
* Clear all registered plugin commands.
* Called during plugin reload.
*/
export function clearPluginCommands(): void {
pluginCommands.clear();
registryLockCount = 0;
}
/**
* Clear plugin commands for a specific plugin.
*/
export function clearPluginCommandsForPlugin(pluginId: string): void {
for (const [key, cmd] of pluginCommands.entries()) {
if (cmd.pluginId === pluginId) {
pluginCommands.delete(key);
}
}
}
/**
* Check if a command body matches a registered plugin command.
* Returns the command definition and parsed args if matched.
*
* Note: If a command has `acceptsArgs: false` and the user provides arguments,
* the command will not match. This allows the message to fall through to
* built-in handlers or the agent. Document this behavior to plugin authors.
*/
export function matchPluginCommand(
commandBody: string,
): { command: RegisteredPluginCommand; args?: string } | null {
const trimmed = commandBody.trim();
if (!trimmed.startsWith("/")) return null;
// Extract command name and args
const spaceIndex = trimmed.indexOf(" ");
const commandName = spaceIndex === -1 ? trimmed : trimmed.slice(0, spaceIndex);
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
const key = commandName.toLowerCase();
const command = pluginCommands.get(key);
if (!command) return null;
// If command doesn't accept args but args were provided, don't match
if (args && !command.acceptsArgs) return null;
return { command, args: args || undefined };
}
/**
* Sanitize command arguments to prevent injection attacks.
* Removes control characters and enforces length limits.
*/
function sanitizeArgs(args: string | undefined): string | undefined {
if (!args) return undefined;
// Enforce length limit
const trimmed = args.length > MAX_ARGS_LENGTH ? args.slice(0, MAX_ARGS_LENGTH) : args;
// Remove control characters (except newlines and tabs which may be intentional)
let needsSanitize = false;
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a) continue;
if (code < 0x20 || code === 0x7f) {
needsSanitize = true;
break;
}
}
if (!needsSanitize) return trimmed;
let sanitized = "";
for (let i = 0; i < trimmed.length; i += 1) {
const code = trimmed.charCodeAt(i);
if (code === 0x09 || code === 0x0a || (code >= 0x20 && code !== 0x7f)) {
sanitized += trimmed[i];
}
}
return sanitized;
}
/**
* Execute a plugin command handler.
*
* Note: Plugin authors should still validate and sanitize ctx.args for their
* specific use case. This function provides basic defense-in-depth sanitization.
*/
export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
channel: string;
isAuthorizedSender: boolean;
commandBody: string;
config: ClawdbotConfig;
}): Promise<{ text: string }> {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
// Check authorization
const requireAuth = command.requireAuth !== false; // Default to true
if (requireAuth && !isAuthorizedSender) {
logVerbose(
`Plugin command /${command.name} blocked: unauthorized sender ${senderId || "<unknown>"}`,
);
return { text: "⚠️ This command requires authorization." };
}
// Sanitize args before passing to handler
const sanitizedArgs = sanitizeArgs(args);
const ctx: PluginCommandContext = {
senderId,
channel,
isAuthorizedSender,
args: sanitizedArgs,
commandBody,
config,
};
// Lock registry during execution to prevent concurrent modifications
registryLockCount += 1;
try {
const result = await command.handler(ctx);
logVerbose(
`Plugin command /${command.name} executed successfully for ${senderId || "unknown"}`,
);
return { text: result.text };
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
// Don't leak internal error details - return a safe generic message
return { text: "⚠️ Command failed. Please try again later." };
} finally {
registryLockCount = Math.max(0, registryLockCount - 1);
}
}
/**
* List all registered plugin commands.
* Used for /help and /commands output.
*/
export function listPluginCommands(): Array<{
name: string;
description: string;
pluginId: string;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
pluginId: cmd.pluginId,
}));
}
/**
* Get plugin command specs for native command registration (e.g., Telegram).
*/
export function getPluginCommandSpecs(): Array<{
name: string;
description: string;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
acceptsArgs: Boolean(cmd.acceptsArgs),
}));
}

View File

@ -16,6 +16,7 @@ import {
type NormalizedPluginsConfig, type NormalizedPluginsConfig,
} from "./config-state.js"; } from "./config-state.js";
import { initializeGlobalHookRunner } from "./hook-runner-global.js"; import { initializeGlobalHookRunner } from "./hook-runner-global.js";
import { clearPluginCommands } from "./commands.js";
import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js"; import { createPluginRegistry, type PluginRecord, type PluginRegistry } from "./registry.js";
import { createPluginRuntime } from "./runtime/index.js"; import { createPluginRuntime } from "./runtime/index.js";
import { setActivePluginRegistry } from "./runtime.js"; import { setActivePluginRegistry } from "./runtime.js";
@ -147,6 +148,7 @@ function createPluginRecord(params: {
gatewayMethods: [], gatewayMethods: [],
cliCommands: [], cliCommands: [],
services: [], services: [],
commands: [],
httpHandlers: 0, httpHandlers: 0,
hookCount: 0, hookCount: 0,
configSchema: params.configSchema, configSchema: params.configSchema,
@ -177,6 +179,9 @@ export function loadClawdbotPlugins(options: PluginLoadOptions = {}): PluginRegi
} }
} }
// Clear previously registered plugin commands before reloading
clearPluginCommands();
const runtime = createPluginRuntime(); const runtime = createPluginRuntime();
const { registry, createApi } = createPluginRegistry({ const { registry, createApi } = createPluginRegistry({
logger, logger,

View File

@ -11,6 +11,7 @@ import type {
ClawdbotPluginApi, ClawdbotPluginApi,
ClawdbotPluginChannelRegistration, ClawdbotPluginChannelRegistration,
ClawdbotPluginCliRegistrar, ClawdbotPluginCliRegistrar,
ClawdbotPluginCommandDefinition,
ClawdbotPluginHttpHandler, ClawdbotPluginHttpHandler,
ClawdbotPluginHookOptions, ClawdbotPluginHookOptions,
ProviderPlugin, ProviderPlugin,
@ -26,6 +27,7 @@ import type {
PluginHookHandlerMap, PluginHookHandlerMap,
PluginHookRegistration as TypedPluginHookRegistration, PluginHookRegistration as TypedPluginHookRegistration,
} from "./types.js"; } from "./types.js";
import { registerPluginCommand } from "./commands.js";
import type { PluginRuntime } from "./runtime/types.js"; import type { PluginRuntime } from "./runtime/types.js";
import type { HookEntry } from "../hooks/types.js"; import type { HookEntry } from "../hooks/types.js";
import path from "node:path"; import path from "node:path";
@ -77,6 +79,12 @@ export type PluginServiceRegistration = {
source: string; source: string;
}; };
export type PluginCommandRegistration = {
pluginId: string;
command: ClawdbotPluginCommandDefinition;
source: string;
};
export type PluginRecord = { export type PluginRecord = {
id: string; id: string;
name: string; name: string;
@ -96,6 +104,7 @@ export type PluginRecord = {
gatewayMethods: string[]; gatewayMethods: string[];
cliCommands: string[]; cliCommands: string[];
services: string[]; services: string[];
commands: string[];
httpHandlers: number; httpHandlers: number;
hookCount: number; hookCount: number;
configSchema: boolean; configSchema: boolean;
@ -114,6 +123,7 @@ export type PluginRegistry = {
httpHandlers: PluginHttpRegistration[]; httpHandlers: PluginHttpRegistration[];
cliRegistrars: PluginCliRegistration[]; cliRegistrars: PluginCliRegistration[];
services: PluginServiceRegistration[]; services: PluginServiceRegistration[];
commands: PluginCommandRegistration[];
diagnostics: PluginDiagnostic[]; diagnostics: PluginDiagnostic[];
}; };
@ -135,6 +145,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
httpHandlers: [], httpHandlers: [],
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
commands: [],
diagnostics: [], diagnostics: [],
}; };
const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {}));
@ -352,6 +363,39 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
}); });
}; };
const registerCommand = (record: PluginRecord, command: ClawdbotPluginCommandDefinition) => {
const name = command.name.trim();
if (!name) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: "command registration missing name",
});
return;
}
// Register with the plugin command system (validates name and checks for duplicates)
const normalizedCommand = { ...command, name };
const result = registerPluginCommand(record.id, normalizedCommand);
if (!result.ok) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `command registration failed: ${result.error}`,
});
return;
}
record.commands.push(name);
registry.commands.push({
pluginId: record.id,
command: normalizedCommand,
source: record.source,
});
};
const registerTypedHook = <K extends PluginHookName>( const registerTypedHook = <K extends PluginHookName>(
record: PluginRecord, record: PluginRecord,
hookName: K, hookName: K,
@ -401,6 +445,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler), registerGatewayMethod: (method, handler) => registerGatewayMethod(record, method, handler),
registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerCli: (registrar, opts) => registerCli(record, registrar, opts),
registerService: (service) => registerService(record, service), registerService: (service) => registerService(record, service),
registerCommand: (command) => registerCommand(record, command),
resolvePath: (input: string) => resolveUserPath(input), resolvePath: (input: string) => resolveUserPath(input),
on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts), on: (hookName, handler, opts) => registerTypedHook(record, hookName, handler, opts),
}; };
@ -416,6 +461,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
registerGatewayMethod, registerGatewayMethod,
registerCli, registerCli,
registerService, registerService,
registerCommand,
registerHook, registerHook,
registerTypedHook, registerTypedHook,
}; };

View File

@ -11,6 +11,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
httpHandlers: [], httpHandlers: [],
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
commands: [],
diagnostics: [], diagnostics: [],
}); });

View File

@ -129,6 +129,59 @@ export type ClawdbotPluginGatewayMethod = {
handler: GatewayRequestHandler; handler: GatewayRequestHandler;
}; };
// =============================================================================
// Plugin Commands
// =============================================================================
/**
* Context passed to plugin command handlers.
*/
export type PluginCommandContext = {
/** The sender's identifier (e.g., Telegram user ID) */
senderId?: string;
/** The channel/surface (e.g., "telegram", "discord") */
channel: string;
/** Whether the sender is on the allowlist */
isAuthorizedSender: boolean;
/** Raw command arguments after the command name */
args?: string;
/** The full normalized command body */
commandBody: string;
/** Current clawdbot configuration */
config: ClawdbotConfig;
};
/**
* Result returned by a plugin command handler.
*/
export type PluginCommandResult = {
/** Text response to send back to the user */
text: string;
};
/**
* Handler function for plugin commands.
*/
export type PluginCommandHandler = (
ctx: PluginCommandContext,
) => PluginCommandResult | Promise<PluginCommandResult>;
/**
* Definition for a plugin-registered command.
*/
export type ClawdbotPluginCommandDefinition = {
/** Command name without leading slash (e.g., "tts_on") */
name: string;
/** Description shown in /help and command menus */
description: string;
/** Whether this command accepts arguments */
acceptsArgs?: boolean;
/** Whether only authorized senders can use this command (default: true) */
requireAuth?: boolean;
/** The handler function */
handler: PluginCommandHandler;
};
export type ClawdbotPluginHttpHandler = ( export type ClawdbotPluginHttpHandler = (
req: IncomingMessage, req: IncomingMessage,
res: ServerResponse, res: ServerResponse,
@ -201,6 +254,12 @@ export type ClawdbotPluginApi = {
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void; registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: ClawdbotPluginService) => void; registerService: (service: ClawdbotPluginService) => void;
registerProvider: (provider: ProviderPlugin) => void; registerProvider: (provider: ProviderPlugin) => void;
/**
* Register a custom command that bypasses the LLM agent.
* Plugin commands are processed before built-in commands and before agent invocation.
* Use this for simple state-toggling or status commands that don't need AI reasoning.
*/
registerCommand: (command: ClawdbotPluginCommandDefinition) => void;
resolvePath: (input: string) => string; resolvePath: (input: string) => string;
/** Register a lifecycle hook handler */ /** Register a lifecycle hook handler */
on: <K extends PluginHookName>( on: <K extends PluginHookName>(

View File

@ -6,6 +6,7 @@ import {
findCommandByNativeName, findCommandByNativeName,
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
normalizeNativeCommandSpecsForSurface,
parseCommandArgs, parseCommandArgs,
resolveCommandArgMenu, resolveCommandArgMenu,
} from "../auto-reply/commands-registry.js"; } from "../auto-reply/commands-registry.js";
@ -84,13 +85,28 @@ export const registerTelegramNativeCommands = ({
}: RegisterTelegramNativeCommandsParams) => { }: RegisterTelegramNativeCommandsParams) => {
const skillCommands = const skillCommands =
nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : []; nativeEnabled && nativeSkillsEnabled ? listSkillCommandsForAgents({ cfg }) : [];
const nativeCommands = nativeEnabled const rawNativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands }) ? listNativeCommandSpecsForConfig(cfg, { skillCommands })
: []; : [];
const nativeCommands = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: rawNativeCommands,
});
const reservedCommands = new Set( const reservedCommands = new Set(
listNativeCommandSpecs().map((command) => command.name.toLowerCase()), normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: listNativeCommandSpecs(),
}).map((command) => command.name.toLowerCase()),
); );
for (const command of skillCommands) { const reservedSkillSpecs = normalizeNativeCommandSpecsForSurface({
surface: "telegram",
specs: skillCommands.map((command) => ({
name: command.name,
description: command.description,
acceptsArgs: true,
})),
});
for (const command of reservedSkillSpecs) {
reservedCommands.add(command.name.toLowerCase()); reservedCommands.add(command.name.toLowerCase());
} }
const customResolution = resolveTelegramCustomCommands({ const customResolution = resolveTelegramCustomCommands({

View File

@ -19,6 +19,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
httpHandlers: [], httpHandlers: [],
cliRegistrars: [], cliRegistrars: [],
services: [], services: [],
commands: [],
diagnostics: [], diagnostics: [],
}); });