fix(plugins): scope commands to channels

This commit is contained in:
Vincent Koc
2026-05-04 02:57:58 -07:00
parent 3434cfa381
commit feb9a5af6a
9 changed files with 127 additions and 17 deletions

View File

@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/commands: scope QQBot framework slash commands to the QQBot channel so `/bot-*` command handlers and native specs do not leak onto unrelated chat surfaces. Thanks @vincentkoc.
- fix: harden backend message action gateway routing [AI]. (#76374) Thanks @pgondhi987.
- Gate QQBot streaming command auth [AI]. (#76375) Thanks @pgondhi987.
- Plugins/install: suppress dangerous-pattern scanner warnings for trusted official OpenClaw npm installs, so installing `@openclaw/discord` no longer prints credential-harvesting warnings for the official package.

View File

@@ -76,6 +76,7 @@ describe("registerQQBotFrameworkCommands", () => {
const command = findCommand(registerCommands(), "bot-streaming");
expect(command.requireAuth).toBe(true);
expect(command.channels).toEqual(["qqbot"]);
});
it("preserves the private-chat guard for bot-streaming on generic framework calls", async () => {

View File

@@ -37,6 +37,7 @@ export function registerQQBotFrameworkCommands(api: OpenClawPluginApi): void {
api.registerCommand({
name: cmd.name,
description: cmd.description,
channels: ["qqbot"],
requireAuth: true,
acceptsArgs: true,
handler: async (ctx: PluginCommandContext) => {

View File

@@ -26,7 +26,7 @@ export const handlePluginCommand: CommandHandler = async (
}
// Try to match a plugin command
const match = matchPluginCommand(command.commandBodyNormalized);
const match = matchPluginCommand(command.commandBodyNormalized, { channel: command.channel });
if (!match) {
return null;
}

View File

@@ -148,6 +148,19 @@ export function validatePluginCommandDefinition(
: "Command requiredScopes contains unknown operator scope";
}
}
if (command.channels !== undefined) {
if (!Array.isArray(command.channels)) {
return "Command channels must be an array of channel ids";
}
for (const [index, channel] of (command.channels as readonly unknown[]).entries()) {
if (typeof channel !== "string") {
return `Command channel ${index + 1} must be a string`;
}
if (!channel.trim()) {
return `Command channel ${index + 1} cannot be empty`;
}
}
}
const nameError = validateCommandName(command.name.trim(), opts);
if (nameError) {
return nameError;
@@ -200,6 +213,19 @@ export function listPluginInvocationKeys(command: OpenClawPluginCommandDefinitio
return [...keys];
}
export function pluginCommandSupportsChannel(
command: OpenClawPluginCommandDefinition,
channel?: string,
): boolean {
if (!command.channels || command.channels.length === 0 || !channel) {
return true;
}
const normalizedChannel = normalizeLowercaseStringOrEmpty(channel);
return command.channels.some(
(entry) => normalizeLowercaseStringOrEmpty(entry) === normalizedChannel,
);
}
export function registerPluginCommand(
pluginId: string,
command: OpenClawPluginCommandDefinition,
@@ -228,6 +254,9 @@ export function registerPluginCommand(
...command,
name,
description,
...(command.channels
? { channels: command.channels.map((channel) => normalizeLowercaseStringOrEmpty(channel)) }
: {}),
...(command.agentPromptGuidance
? { agentPromptGuidance: command.agentPromptGuidance.map((line) => line.trim()) }
: {}),

View File

@@ -2,6 +2,7 @@ import { getLoadedChannelPlugin } from "../channels/plugins/index.js";
import { resolveReadOnlyChannelCommandDefaults } from "../channels/plugins/read-only-command-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { pluginCommandSupportsChannel } from "./command-registration.js";
import { pluginCommands } from "./command-registry-state.js";
import type { OpenClawPluginCommandDefinition } from "./types.js";
@@ -60,20 +61,22 @@ export function listProviderPluginCommandSpecs(provider?: string): Array<{
descriptionLocalizations?: Record<string, string>;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => {
const spec: {
name: string;
description: string;
descriptionLocalizations?: Record<string, string>;
acceptsArgs: boolean;
} = {
name: resolvePluginNativeName(cmd, provider),
description: cmd.description,
acceptsArgs: cmd.acceptsArgs ?? false,
};
if (cmd.descriptionLocalizations) {
spec.descriptionLocalizations = cmd.descriptionLocalizations;
}
return spec;
});
return Array.from(pluginCommands.values())
.filter((cmd) => pluginCommandSupportsChannel(cmd, provider))
.map((cmd) => {
const spec: {
name: string;
description: string;
descriptionLocalizations?: Record<string, string>;
acceptsArgs: boolean;
} = {
name: resolvePluginNativeName(cmd, provider),
description: cmd.description,
acceptsArgs: cmd.acceptsArgs ?? false,
};
if (cmd.descriptionLocalizations) {
spec.descriptionLocalizations = cmd.descriptionLocalizations;
}
return spec;
});
}

View File

@@ -319,6 +319,19 @@ describe("registerPluginCommand", () => {
error: "Agent prompt guidance must be an array of strings",
},
},
{
name: "rejects invalid channel scopes",
command: {
name: "demo",
description: "Demo",
channels: ["telegram", " "],
handler: async () => ({ text: "ok" }),
},
expected: {
ok: false,
error: "Command channel 2 cannot be empty",
},
},
] as const)("$name", ({ command, expected }) => {
expect(registerPluginCommand("demo-plugin", command)).toEqual(expected);
});
@@ -384,6 +397,31 @@ describe("registerPluginCommand", () => {
]);
});
it("scopes plugin command matches and native specs to configured channels", () => {
const result = registerVoiceCommandForTest({
channels: [" Telegram "],
description: "Demo command",
});
expect(result).toEqual({ ok: true });
expect(matchPluginCommand("/voice", { channel: "telegram" })).toMatchObject({
command: expect.objectContaining({
name: "voice",
channels: ["telegram"],
}),
});
expect(matchPluginCommand("/voice", { channel: "discord" })).toBeNull();
expect(matchPluginCommand("/voice")).toMatchObject({
command: expect.objectContaining({ name: "voice" }),
});
expectProviderCommandSpecCases([
{ provider: undefined, expectedNames: ["voice"] },
{ provider: "telegram", expectedNames: ["voice"] },
{ provider: "discord", expectedNames: [] },
]);
expect(listProviderPluginCommandSpecs("discord")).toEqual([]);
});
it("allows Slack to resolve provider-native plugin specs without changing shared native gating", () => {
const result = registerVoiceCommandForTest({
nativeNames: {
@@ -570,6 +608,31 @@ describe("registerPluginCommand", () => {
expect(observedOwnerStatus).toBeUndefined();
});
it("skips direct plugin command execution on unsupported channels", async () => {
let handlerCalled = false;
const handler = async () => {
handlerCalled = true;
return { text: "ok" };
};
const result = await executePluginCommand({
command: {
name: "voice",
description: "Voice command",
channels: ["qqbot"],
handler,
pluginId: "demo-plugin",
},
channel: "discord",
isAuthorizedSender: true,
commandBody: "/voice",
config: {},
});
expect(result).toEqual({ continueAgent: true });
expect(handlerCalled).toBe(false);
});
it("does not allow direct reserved command registrations to claim owner status", () => {
const result = registerPluginCommand(
"codex",

View File

@@ -15,6 +15,7 @@ import {
clearPluginCommandsForPlugin,
isReservedCommandName,
listPluginInvocationKeys,
pluginCommandSupportsChannel,
registerPluginCommand,
validateCommandName,
validatePluginCommandDefinition,
@@ -61,6 +62,7 @@ export {
*/
export function matchPluginCommand(
commandBody: string,
options: { channel?: string } = {},
): { command: RegisteredPluginCommand; args?: string } | null {
const trimmed = commandBody.trim();
if (!trimmed.startsWith("/")) {
@@ -89,6 +91,7 @@ export function matchPluginCommand(
listPluginInvocationNames(candidate).includes(candidateKey),
),
)
.filter((candidate) => candidate && pluginCommandSupportsChannel(candidate, options.channel))
.find(Boolean) ?? null;
if (!command) {
@@ -197,6 +200,10 @@ export async function executePluginCommand(params: {
const { command, args, senderId, channel, isAuthorizedSender, commandBody, config } = params;
// Check authorization
if (!pluginCommandSupportsChannel(command, channel)) {
logVerbose(`Plugin command /${command.name} skipped on unsupported channel ${channel}`);
return { continueAgent: true };
}
const requireAuth = command.requireAuth !== false; // Default to true
if (requireAuth && !isAuthorizedSender) {
logVerbose(

View File

@@ -1969,6 +1969,11 @@ export type OpenClawPluginCommandDefinition = {
description: string;
/** Localized descriptions for native command surfaces that support them. */
descriptionLocalizations?: Record<string, string>;
/**
* Optional channel ids this command belongs to.
* Omit to keep the command available on every channel surface.
*/
channels?: readonly string[];
/** Optional system-prompt guidance for agents when this command is registered. */
agentPromptGuidance?: readonly string[];
/** Whether this command accepts arguments */