Plugins: honor native command aliases at dispatch

This commit is contained in:
Vincent Koc 2026-03-16 21:01:10 -07:00
parent 095a9f6e1d
commit f90d432de3
4 changed files with 147 additions and 1 deletions

View File

@ -212,6 +212,58 @@ describe("Discord native plugin command dispatch", () => {
);
});
it("round-trips Discord native aliases through the real plugin registry", async () => {
const cfg = createConfig();
const commandSpec: NativeCommandSpec = {
name: "pairdiscord",
description: "Pair",
acceptsArgs: true,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const interaction = createInteraction();
expect(
registerPluginCommand("demo-plugin", {
name: "pair",
nativeNames: {
telegram: "pair_device",
discord: "pairdiscord",
},
description: "Pair device",
acceptsArgs: true,
requireAuth: false,
handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }),
}),
).toEqual({ ok: true });
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
await (command as { run: (interaction: unknown) => Promise<void> }).run(
Object.assign(interaction, {
options: {
getString: () => "now",
getBoolean: () => null,
getFocused: () => "",
},
}) as unknown,
);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({ content: "paired:now" }),
);
});
it("blocks unauthorized Discord senders before requireAuth:false plugin commands execute", async () => {
const cfg = {
commands: {

View File

@ -147,6 +147,54 @@ describe("registerTelegramNativeCommands real plugin registry", () => {
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("round-trips Telegram native aliases through the real plugin registry", async () => {
const { bot, commandHandlers, sendMessage, setMyCommands } = createCommandBot();
expect(
registerPluginCommand("demo-plugin", {
name: "pair",
nativeNames: {
telegram: "pair_device",
discord: "pairdiscord",
},
description: "Pair device",
acceptsArgs: true,
requireAuth: false,
handler: async ({ args }) => ({ text: `paired:${args ?? ""}` }),
}),
).toEqual({ ok: true });
registerTelegramNativeCommands({
...buildParams({}),
bot,
});
const registeredCommands = await waitForRegisteredCommands(setMyCommands);
expect(registeredCommands).toEqual(
expect.arrayContaining([{ command: "pair_device", description: "Pair device" }]),
);
const handler = commandHandlers.get("pair_device");
expect(handler).toBeTruthy();
await handler?.({
match: "now",
message: {
message_id: 2,
date: Math.floor(Date.now() / 1000),
chat: { id: 123, type: "private" },
from: { id: 456, username: "alice" },
},
});
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
expect.objectContaining({
replies: [expect.objectContaining({ text: "paired:now" })],
}),
);
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
});
it("keeps real plugin command handlers available when native menu registration is disabled", () => {
const { bot, commandHandlers, setMyCommands } = createCommandBot();

View File

@ -7,6 +7,7 @@ import {
executePluginCommand,
getPluginCommandSpecs,
listPluginCommands,
matchPluginCommand,
registerPluginCommand,
} from "./commands.js";
import { setActivePluginRegistry } from "./runtime.js";
@ -107,6 +108,29 @@ describe("registerPluginCommand", () => {
expect(getPluginCommandSpecs("slack")).toEqual([]);
});
it("matches provider-specific native aliases back to the canonical command", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
acceptsArgs: true,
handler: async () => ({ text: "ok" }),
});
expect(result).toEqual({ ok: true });
expect(matchPluginCommand("/talkvoice now")).toMatchObject({
command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }),
args: "now",
});
expect(matchPluginCommand("/discordvoice now")).toMatchObject({
command: expect.objectContaining({ name: "voice", pluginId: "demo-plugin" }),
args: "now",
});
});
it("resolves Discord DM command bindings with the user target prefix intact", () => {
expect(
__testing.resolveBindingConversationFromCommand({

View File

@ -219,7 +219,11 @@ export function matchPluginCommand(
const args = spaceIndex === -1 ? undefined : trimmed.slice(spaceIndex + 1).trim();
const key = commandName.toLowerCase();
const command = pluginCommands.get(key);
const command =
pluginCommands.get(key) ??
Array.from(pluginCommands.values()).find((candidate) =>
listPluginInvocationNames(candidate).includes(key),
);
if (!command) {
return null;
@ -458,6 +462,24 @@ function resolvePluginNativeName(
return command.name;
}
function listPluginInvocationNames(command: OpenClawPluginCommandDefinition): string[] {
const names = new Set<string>();
const push = (value: string | undefined) => {
const normalized = value?.trim().toLowerCase();
if (!normalized) {
return;
}
names.add(`/${normalized}`);
};
push(command.name);
push(command.nativeNames?.default);
push(command.nativeNames?.telegram);
push(command.nativeNames?.discord);
return [...names];
}
/**
* Get plugin command specs for native command registration (e.g., Telegram).
*/