diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c77ad198ff..bf39f378425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.test.ts b/extensions/qqbot/src/bridge/commands/framework-registration.test.ts index b710196461c..d6c18ea8233 100644 --- a/extensions/qqbot/src/bridge/commands/framework-registration.test.ts +++ b/extensions/qqbot/src/bridge/commands/framework-registration.test.ts @@ -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 () => { diff --git a/extensions/qqbot/src/bridge/commands/framework-registration.ts b/extensions/qqbot/src/bridge/commands/framework-registration.ts index 7109d3293d5..d2b4691fe79 100644 --- a/extensions/qqbot/src/bridge/commands/framework-registration.ts +++ b/extensions/qqbot/src/bridge/commands/framework-registration.ts @@ -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) => { diff --git a/src/auto-reply/reply/commands-plugin.ts b/src/auto-reply/reply/commands-plugin.ts index 5dad291a5a4..651bef77f3f 100644 --- a/src/auto-reply/reply/commands-plugin.ts +++ b/src/auto-reply/reply/commands-plugin.ts @@ -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; } diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index c186148e929..2d773e92fca 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -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()) } : {}), diff --git a/src/plugins/command-specs.ts b/src/plugins/command-specs.ts index b8fbf1099be..d682a92669a 100644 --- a/src/plugins/command-specs.ts +++ b/src/plugins/command-specs.ts @@ -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; acceptsArgs: boolean; }> { - return Array.from(pluginCommands.values()).map((cmd) => { - const spec: { - name: string; - description: string; - descriptionLocalizations?: Record; - 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; + acceptsArgs: boolean; + } = { + name: resolvePluginNativeName(cmd, provider), + description: cmd.description, + acceptsArgs: cmd.acceptsArgs ?? false, + }; + if (cmd.descriptionLocalizations) { + spec.descriptionLocalizations = cmd.descriptionLocalizations; + } + return spec; + }); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 3a2b964a862..b56d4879d32 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -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", diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index bf32ab69168..bc438c3829e 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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( diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 2c287edf71b..2280ecbca75 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1969,6 +1969,11 @@ export type OpenClawPluginCommandDefinition = { description: string; /** Localized descriptions for native command surfaces that support them. */ descriptionLocalizations?: Record; + /** + * 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 */