From be9ea991de31974b51f68ee9c40a3af792d00c29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 7 Mar 2026 21:59:12 +0000 Subject: [PATCH] fix(discord): avoid native plugin command collisions --- CHANGELOG.md | 1 + docs/tools/plugin.md | 1 + extensions/talk-voice/index.ts | 18 ++++++++++---- src/discord/monitor/provider.test.ts | 1 + src/discord/monitor/provider.ts | 2 +- src/plugins/commands.test.ts | 35 ++++++++++++++++++++++++++++ src/plugins/commands.ts | 20 ++++++++++++++-- src/plugins/types.ts | 6 +++++ src/telegram/bot-native-commands.ts | 2 +- 9 files changed, 77 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3773321c3..4ce6381ea07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 77666b7ac11..a257d8b7a45 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -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) diff --git a/extensions/talk-voice/index.ts b/extensions/talk-voice/index.ts index 4473fa05ea9..3445e91e81f 100644 --- a/extensions/talk-voice/index.ts +++ b/extensions/talk-voice/index.ts @@ -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 " }; + return { text: `Usage: ${commandLabel} set ` }; } 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 ", + `${commandLabel} status`, + `${commandLabel} list [limit]`, + `${commandLabel} set `, ].join("\n"), }; }, diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 3a52f1eb989..0e79e476382 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -720,6 +720,7 @@ describe("monitorDiscordProvider", () => { const commandNames = (createDiscordNativeCommandMock.mock.calls as Array) .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"); }); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index c9f9f3d4b49..2a9791d2642 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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; diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 9f183eeafe7..34d411702a0 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -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, + }, + ]); + }); }); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 469a4c01521..f0ec39539c8 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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, })); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 32f8a545038..4c5894ddda1 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -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> & { default?: string }; /** Description shown in /help and command menus */ description: string; /** Whether this command accepts arguments */ diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index cc00a46dd8a..94dcd111ba1 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -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)),