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>
This commit is contained in:
Glucksberg
2026-01-23 03:17:10 +00:00
committed by Peter Steinberger
parent 66eec295b8
commit 4ee808dbcb
11 changed files with 355 additions and 0 deletions

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

@@ -0,0 +1,151 @@
/**
* 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 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();
/**
* Register a plugin command.
*/
export function registerPluginCommand(
pluginId: string,
command: ClawdbotPluginCommandDefinition,
): void {
const key = `/${command.name.toLowerCase()}`;
if (pluginCommands.has(key)) {
logVerbose(
`Plugin command ${key} already registered, overwriting with plugin ${pluginId}`,
);
}
pluginCommands.set(key, { ...command, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
}
/**
* Clear all registered plugin commands.
* Called during plugin reload.
*/
export function clearPluginCommands(): void {
pluginCommands.clear();
}
/**
* 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.
*/
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 };
}
/**
* Execute a plugin command handler.
*/
export async function executePluginCommand(params: {
command: RegisteredPluginCommand;
args?: string;
senderId?: string;
channel: string;
isAuthorizedSender: boolean;
commandBody: string;
config: ClawdbotConfig;
}): Promise<{ text: string } | null> {
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 null; // Silently ignore unauthorized commands
}
const ctx: PluginCommandContext = {
senderId,
channel,
isAuthorizedSender,
args,
commandBody,
config,
};
try {
const result = await command.handler(ctx);
return { text: result.text };
} catch (err) {
const error = err as Error;
logVerbose(`Plugin command /${command.name} error: ${error.message}`);
return { text: `⚠️ Command failed: ${error.message}` };
}
}
/**
* 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;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
}));
}

View File

@@ -147,6 +147,7 @@ function createPluginRecord(params: {
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpHandlers: 0,
hookCount: 0,
configSchema: params.configSchema,

View File

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

View File

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

View File

@@ -129,6 +129,59 @@ export type ClawdbotPluginGatewayMethod = {
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 = (
req: IncomingMessage,
res: ServerResponse,
@@ -201,6 +254,12 @@ export type ClawdbotPluginApi = {
registerCli: (registrar: ClawdbotPluginCliRegistrar, opts?: { commands?: string[] }) => void;
registerService: (service: ClawdbotPluginService) => 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;
/** Register a lifecycle hook handler */
on: <K extends PluginHookName>(