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:
committed by
Peter Steinberger
parent
66eec295b8
commit
4ee808dbcb
@@ -62,6 +62,7 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -494,6 +495,62 @@ 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)
|
||||
- `requireAuth`: Whether to require authorized sender (default: false)
|
||||
- `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`)
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
|
||||
46
src/auto-reply/reply/commands-plugin.ts
Normal file
46
src/auto-reply/reply/commands-plugin.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 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> => {
|
||||
const { command, cfg } = params;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
|
||||
// Execute the plugin command
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId: command.senderId,
|
||||
channel: command.channel,
|
||||
isAuthorizedSender: command.isAuthorizedSender,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: result.text },
|
||||
};
|
||||
}
|
||||
|
||||
// Command was blocked (e.g., unauthorized) - don't continue to agent
|
||||
return { shouldContinue: false };
|
||||
};
|
||||
@@ -12,6 +12,7 @@ export const createTestRegistry = (overrides: Partial<PluginRegistry> = {}): Plu
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
const merged = { ...base, ...overrides };
|
||||
|
||||
@@ -140,6 +140,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
151
src/plugins/commands.ts
Normal file
151
src/plugins/commands.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -147,6 +147,7 @@ function createPluginRecord(params: {
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpHandlers: 0,
|
||||
hookCount: 0,
|
||||
configSchema: params.configSchema,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ const createEmptyRegistry = (): PluginRegistry => ({
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -19,6 +19,7 @@ export const createTestRegistry = (channels: PluginRegistry["channels"] = []): P
|
||||
httpHandlers: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user