fix(discord): avoid native plugin command collisions

This commit is contained in:
Peter Steinberger 2026-03-07 21:59:12 +00:00
parent 4dcd930923
commit be9ea991de
9 changed files with 77 additions and 9 deletions

View File

@ -283,6 +283,7 @@ Docs: https://docs.openclaw.ai
- Discord inbound listener non-blocking dispatch: make `MESSAGE_CREATE` listener handoff asynchronous (no per-listener queue blocking), so long runs no longer stall unrelated incoming events. (#39154) Thanks @yaseenkadlemakki.
- Daemon/Windows PATH freeze fix: stop persisting install-time `PATH` snapshots into Scheduled Task scripts so runtime tool lookup follows current host PATH updates; also refresh local TUI history on silent local finals. (#39139) Thanks @Narcooo.
- Gateway/systemd service restart hardening: clear stale gateway listeners by explicit run-port before service bind, add restart stale-pid port-override support, tune systemd start/stop/exit handling, and disable detached child mode only in service-managed runtime so cgroup stop semantics clean up descendants reliably. (#38463) Thanks @spirittechie.
- Discord/plugin native command aliases: let plugins declare provider-specific slash names so native Discord registration can avoid built-in command collisions; the bundled Talk voice plugin now uses `/talkvoice` natively on Discord while keeping text `/voice`.
## 2026.3.2

View File

@ -863,6 +863,7 @@ Command handler context:
Command options:
- `name`: Command name (without the leading `/`)
- `nativeNames`: Optional native-command aliases for slash/menu surfaces. Use `default` for all native providers, or provider-specific keys like `discord`
- `description`: Help text shown in command lists
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
- `requireAuth`: Whether to require authorized sender (default: true)

View File

@ -77,12 +77,20 @@ function asTrimmedString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveCommandLabel(channel: string): string {
return channel === "discord" ? "/talkvoice" : "/voice";
}
export default function register(api: OpenClawPluginApi) {
api.registerCommand({
name: "voice",
nativeNames: {
discord: "talkvoice",
},
description: "List/set ElevenLabs Talk voice (affects iOS Talk playback).",
acceptsArgs: true,
handler: async (ctx) => {
const commandLabel = resolveCommandLabel(ctx.channel);
const args = ctx.args?.trim() ?? "";
const tokens = args.split(/\s+/).filter(Boolean);
const action = (tokens[0] ?? "status").toLowerCase();
@ -118,13 +126,13 @@ export default function register(api: OpenClawPluginApi) {
if (action === "set") {
const query = tokens.slice(1).join(" ").trim();
if (!query) {
return { text: "Usage: /voice set <voiceId|name>" };
return { text: `Usage: ${commandLabel} set <voiceId|name>` };
}
const voices = await listVoices(apiKey);
const chosen = findVoice(voices, query);
if (!chosen) {
const hint = isLikelyVoiceId(query) ? query : `"${query}"`;
return { text: `No voice found for ${hint}. Try: /voice list` };
return { text: `No voice found for ${hint}. Try: ${commandLabel} list` };
}
const nextConfig = {
@ -144,9 +152,9 @@ export default function register(api: OpenClawPluginApi) {
text: [
"Voice commands:",
"",
"/voice status",
"/voice list [limit]",
"/voice set <voiceId|name>",
`${commandLabel} status`,
`${commandLabel} list [limit]`,
`${commandLabel} set <voiceId|name>`,
].join("\n"),
};
},

View File

@ -720,6 +720,7 @@ describe("monitorDiscordProvider", () => {
const commandNames = (createDiscordNativeCommandMock.mock.calls as Array<unknown[]>)
.map((call) => (call[0] as { command?: { name?: string } } | undefined)?.command?.name)
.filter((value): value is string => typeof value === "string");
expect(getPluginCommandSpecsMock).toHaveBeenCalledWith("discord");
expect(commandNames).toContain("cmd");
expect(commandNames).toContain("cron_jobs");
});

View File

@ -135,7 +135,7 @@ function appendPluginCommandSpecs(params: {
const existingNames = new Set(
merged.map((spec) => spec.name.trim().toLowerCase()).filter(Boolean),
);
for (const pluginCommand of getPluginCommandSpecs()) {
for (const pluginCommand of getPluginCommandSpecs("discord")) {
const normalizedName = pluginCommand.name.trim().toLowerCase();
if (!normalizedName) {
continue;

View File

@ -59,4 +59,39 @@ describe("registerPluginCommand", () => {
},
]);
});
it("supports provider-specific native command aliases", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",
nativeNames: {
default: "talkvoice",
discord: "discordvoice",
},
description: "Demo command",
handler: async () => ({ text: "ok" }),
});
expect(result).toEqual({ ok: true });
expect(getPluginCommandSpecs()).toEqual([
{
name: "talkvoice",
description: "Demo command",
acceptsArgs: false,
},
]);
expect(getPluginCommandSpecs("discord")).toEqual([
{
name: "discordvoice",
description: "Demo command",
acceptsArgs: false,
},
]);
expect(getPluginCommandSpecs("telegram")).toEqual([
{
name: "talkvoice",
description: "Demo command",
acceptsArgs: false,
},
]);
});
});

View File

@ -316,16 +316,32 @@ export function listPluginCommands(): Array<{
}));
}
function resolvePluginNativeName(
command: OpenClawPluginCommandDefinition,
provider?: string,
): string {
const providerName = provider?.trim().toLowerCase();
const providerOverride = providerName ? command.nativeNames?.[providerName] : undefined;
if (typeof providerOverride === "string" && providerOverride.trim()) {
return providerOverride.trim();
}
const defaultOverride = command.nativeNames?.default;
if (typeof defaultOverride === "string" && defaultOverride.trim()) {
return defaultOverride.trim();
}
return command.name;
}
/**
* Get plugin command specs for native command registration (e.g., Telegram).
*/
export function getPluginCommandSpecs(): Array<{
export function getPluginCommandSpecs(provider?: string): Array<{
name: string;
description: string;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
name: resolvePluginNativeName(cmd, provider),
description: cmd.description,
acceptsArgs: cmd.acceptsArgs ?? false,
}));

View File

@ -186,6 +186,12 @@ export type PluginCommandHandler = (
export type OpenClawPluginCommandDefinition = {
/** Command name without leading slash (e.g., "tts") */
name: string;
/**
* Optional native-command aliases for slash/menu surfaces.
* `default` applies to all native providers unless a provider-specific
* override exists (for example `{ default: "talkvoice", discord: "voice2" }`).
*/
nativeNames?: Partial<Record<string, string>> & { default?: string };
/** Description shown in /help and command menus */
description: string;
/** Whether this command accepts arguments */

View File

@ -359,7 +359,7 @@ export const registerTelegramNativeCommands = ({
runtime.error?.(danger(issue.message));
}
const customCommands = customResolution.commands;
const pluginCommandSpecs = getPluginCommandSpecs();
const pluginCommandSpecs = getPluginCommandSpecs("telegram");
const existingCommands = new Set(
[
...nativeCommands.map((command) => normalizeTelegramCommandName(command.name)),