mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(plugins): scope commands to channels
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) }
|
||||
: {}),
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user