From 4b8641094bb088988d7459fea3138e949f15ec09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:33:16 +0100 Subject: [PATCH] fix(discord): preserve slash command localizations --- CHANGELOG.md | 1 + docs/plugins/sdk-overview.md | 24 +++++++------- docs/tools/slash-commands.md | 2 ++ .../monitor/native-command.options.test.ts | 33 +++++++++++++++++++ .../src/monitor/native-command.options.ts | 19 +++++++++++ .../discord/src/monitor/native-command.ts | 5 +++ src/agents/skills/types.ts | 2 ++ src/auto-reply/commands-registry-list.ts | 26 +++++++++------ src/auto-reply/commands-registry.test.ts | 5 ++- src/auto-reply/commands-registry.ts | 6 +++- src/auto-reply/commands-registry.types.ts | 3 ++ src/plugins/command-registration.ts | 8 +++++ src/plugins/command-specs.ts | 23 ++++++++++--- src/plugins/commands.test.ts | 29 ++++++++++++++++ src/plugins/types.ts | 2 ++ 15 files changed, 159 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a227a36063c..0782708a9b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Discord: retry outbound API calls on HTTP 5xx, request-timeout, and transient transport failures instead of only Discord rate limits, reducing dropped cron and agent replies during short Discord or network outages. Fixes #52396. Thanks @sunshineo. - Discord: include Components v2 Text Display content from referenced replies and forwarded snapshots, so component-only messages still appear in reply context. Fixes #56228. Thanks @HollandDrive. - Discord: add configurable gateway READY timeouts for startup and runtime reconnects, so staggered multi-account setups can avoid false restart loops. Fixes #72273. Thanks @sergionsantos. +- Discord: preserve native slash-command description localizations through command reconcile, so localized Discord descriptions no longer get overwritten by English defaults. Fixes #56580. Thanks @mhseo93. - Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. - Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu. diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 4b155360103..fa5474ac7fd 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -131,18 +131,18 @@ generic contracts; Plan Mode can use them, but so can approval workflows, workspace policy gates, background monitors, setup wizards, and UI companion plugins. -| Method | Contract it owns | -| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | -| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions | -| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session | -| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params | -| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation | -| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true` | -| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces | -| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths | -| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors | -| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle | -| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup | +| Method | Contract it owns | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| `api.registerSessionExtension(...)` | Plugin-owned, JSON-compatible session state projected through Gateway sessions | +| `api.enqueueNextTurnInjection(...)` | Durable exactly-once context injected into the next agent turn for one session | +| `api.registerTrustedToolPolicy(...)` | Bundled/trusted pre-plugin tool policy that can block or rewrite tool params | +| `api.registerToolMetadata(...)` | Tool catalog display metadata without changing the tool implementation | +| `api.registerCommand(...)` | Scoped plugin commands; command results can set `continueAgent: true`; Discord native commands support `descriptionLocalizations` | +| `api.registerControlUiDescriptor(...)` | Control UI contribution descriptors for session, tool, run, or settings surfaces | +| `api.registerRuntimeLifecycle(...)` | Cleanup callbacks for plugin-owned runtime resources on reset/delete/reload paths | +| `api.registerAgentEventSubscription(...)` | Sanitized event subscriptions for workflow state and monitors | +| `api.setRunContext(...)` / `getRunContext(...)` / `clearRunContext(...)` | Per-run plugin scratch state cleared on terminal run lifecycle | +| `api.registerSessionSchedulerJob(...)` | Plugin-owned session scheduler job records with deterministic cleanup | The contracts intentionally split authority: diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index c8df00b788d..8a4492ede86 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -67,6 +67,7 @@ There are two related systems: Registers native commands. Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support. Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup. Slack commands are managed in the Slack app and are not removed automatically. +On Discord, native command specs may include `descriptionLocalizations`, which OpenClaw publishes as Discord `description_localizations` and includes in reconcile comparisons. Registers **skill** commands natively when supported. Auto: on for Discord/Telegram; off for Slack (Slack requires creating a slash command per skill). Set `channels.discord.commands.nativeSkills`, `channels.telegram.commands.nativeSkills`, or `channels.slack.commands.nativeSkills` to override per provider (bool or `"auto"`). @@ -237,6 +238,7 @@ User-invocable skills are also exposed as slash commands: - `/skill [input]` always works as the generic entrypoint. - skills may also appear as direct commands like `/prose` when the skill/plugin registers them. - native skill-command registration is controlled by `commands.nativeSkills` and `channels..commands.nativeSkills`. +- command specs can provide `descriptionLocalizations` for native surfaces that support localized descriptions, including Discord. diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts index 466a821d16c..72541366aeb 100644 --- a/extensions/discord/src/monitor/native-command.options.test.ts +++ b/extensions/discord/src/monitor/native-command.options.test.ts @@ -333,4 +333,37 @@ describe("createDiscordNativeCommand option wiring", () => { expect(requireOption(command, "input").description).toHaveLength(100); expect(requireOption(command, "input").description).toBe("x".repeat(100)); }); + + it("serializes localized command descriptions", () => { + const longDescription = "k".repeat(140); + const command = createDiscordNativeCommand({ + command: { + name: "localized", + description: "Default description", + descriptionLocalizations: { + ko: "현지화된 설명", + "en-GB": longDescription, + }, + acceptsArgs: false, + }, + cfg: {} as OpenClawConfig, + discordConfig: {}, + accountId: "default", + sessionPrefix: "discord:slash", + ephemeralDefault: true, + threadBindings: createNoopThreadBindingManager("default"), + }); + + expect(command.descriptionLocalizations).toEqual({ + ko: "현지화된 설명", + "en-GB": "k".repeat(100), + }); + expect(command.serialize()).toMatchObject({ + description: "Default description", + description_localizations: { + ko: "현지화된 설명", + "en-GB": "k".repeat(100), + }, + }); + }); }); diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts index c51900aebd5..893be1b472a 100644 --- a/extensions/discord/src/monitor/native-command.options.ts +++ b/extensions/discord/src/monitor/native-command.options.ts @@ -28,6 +28,25 @@ export function truncateDiscordCommandDescription(params: { return value.slice(0, DISCORD_COMMAND_DESCRIPTION_MAX); } +export function truncateDiscordCommandDescriptionLocalizations(params: { + value?: Record; + label: string; +}): Record | undefined { + const entries = Object.entries(params.value ?? {}); + if (entries.length === 0) { + return undefined; + } + return Object.fromEntries( + entries.map(([locale, description]) => [ + locale, + truncateDiscordCommandDescription({ + value: description, + label: `${params.label} locale:${locale}`, + }), + ]), + ); +} + function resolveDiscordCommandLogLabel(command: ChatCommandDefinition): string { if (typeof command.nativeName === "string" && command.nativeName.trim().length > 0) { return command.nativeName; diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index b1591cabd64..f759f5b2213 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -72,6 +72,7 @@ import { import { createNativeCommandDefinition, readDiscordCommandArgs } from "./native-command.args.js"; import { buildDiscordCommandOptions, + truncateDiscordCommandDescriptionLocalizations, truncateDiscordCommandDescription, } from "./native-command.options.js"; import { nativeCommandRuntime } from "./native-command.runtime.js"; @@ -146,6 +147,10 @@ export function createDiscordNativeCommand(params: { value: command.description, label: `command:${command.name}`, }); + descriptionLocalizations = truncateDiscordCommandDescriptionLocalizations({ + value: command.descriptionLocalizations, + label: `command:${command.name}`, + }); defer = false; ephemeral = ephemeralDefault; options = options; diff --git a/src/agents/skills/types.ts b/src/agents/skills/types.ts index e7013166eb6..dc4d5eb6f02 100644 --- a/src/agents/skills/types.ts +++ b/src/agents/skills/types.ts @@ -52,6 +52,8 @@ export type SkillCommandSpec = { name: string; skillName: string; description: string; + /** Localized descriptions for native command surfaces that support them. */ + descriptionLocalizations?: Record; /** Optional deterministic dispatch behavior for this command. */ dispatch?: SkillCommandDispatchSpec; /** Native prompt template used by Claude-bundle command markdown files. */ diff --git a/src/auto-reply/commands-registry-list.ts b/src/auto-reply/commands-registry-list.ts index bad3bcce332..53dca563f40 100644 --- a/src/auto-reply/commands-registry-list.ts +++ b/src/auto-reply/commands-registry-list.ts @@ -8,16 +8,22 @@ function buildSkillCommandDefinitions(skillCommands?: SkillCommandSpec[]): ChatC if (!skillCommands || skillCommands.length === 0) { return []; } - return skillCommands.map((spec) => ({ - key: `skill:${spec.skillName}`, - nativeName: spec.name, - description: spec.description, - textAliases: [`/${spec.name}`], - acceptsArgs: true, - argsParsing: "none", - scope: "both", - category: "tools", - })); + return skillCommands.map((spec) => { + const command: ChatCommandDefinition = { + key: `skill:${spec.skillName}`, + nativeName: spec.name, + description: spec.description, + textAliases: [`/${spec.name}`], + acceptsArgs: true, + argsParsing: "none", + scope: "both", + category: "tools", + }; + if (spec.descriptionLocalizations) { + command.descriptionLocalizations = spec.descriptionLocalizations; + } + return command; + }); } export function listChatCommands(params?: { diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index e66496f2244..a9802b93910 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -154,6 +154,7 @@ describe("commands registry", () => { name: "demo_skill", skillName: "demo-skill", description: "Demo skill", + descriptionLocalizations: { ko: "데모 스킬" }, }, ]; const commands = listChatCommandsForConfig( @@ -171,7 +172,9 @@ describe("commands registry", () => { { commands: { config: false, plugins: false, debug: false, native: true } }, { skillCommands }, ); - expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy(); + expect(native.find((spec) => spec.name === "demo_skill")).toMatchObject({ + descriptionLocalizations: { ko: "데모 스킬" }, + }); }); it("applies discord native command overrides", () => { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 314a31ffa67..1dd6609de32 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -84,12 +84,16 @@ function resolveNativeName( } function toNativeCommandSpec(command: ChatCommandDefinition, provider?: string): NativeCommandSpec { - return { + const spec: NativeCommandSpec = { name: resolveNativeName(command, provider) ?? command.key, description: command.description, acceptsArgs: Boolean(command.acceptsArgs), args: command.args, }; + if (command.descriptionLocalizations) { + spec.descriptionLocalizations = command.descriptionLocalizations; + } + return spec; } function listNativeSpecsFromCommands( diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index 46f63b76763..ec8c44f6608 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -59,6 +59,8 @@ export type ChatCommandDefinition = { key: string; nativeName?: string; description: string; + /** Localized descriptions for native command surfaces that support them. */ + descriptionLocalizations?: Record; textAliases: string[]; acceptsArgs?: boolean; args?: CommandArgDefinition[]; @@ -74,6 +76,7 @@ export type ChatCommandDefinition = { export type NativeCommandSpec = { name: string; description: string; + descriptionLocalizations?: Record; acceptsArgs: boolean; args?: CommandArgDefinition[]; }; diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index 9231a6791f6..c186148e929 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -169,6 +169,14 @@ export function validatePluginCommandDefinition( return `Native progress message "${label}" cannot be empty`; } } + for (const [locale, description] of Object.entries(command.descriptionLocalizations ?? {})) { + if (typeof description !== "string") { + return `Description localization "${locale}" must be a string`; + } + if (!description.trim()) { + return `Description localization "${locale}" cannot be empty`; + } + } return null; } diff --git a/src/plugins/command-specs.ts b/src/plugins/command-specs.ts index e8f72963fda..b8fbf1099be 100644 --- a/src/plugins/command-specs.ts +++ b/src/plugins/command-specs.ts @@ -32,6 +32,7 @@ export function getPluginCommandSpecs( ): Array<{ name: string; description: string; + descriptionLocalizations?: Record; acceptsArgs: boolean; }> { const providerName = normalizeOptionalLowercaseString(provider); @@ -56,11 +57,23 @@ export function getPluginCommandSpecs( export function listProviderPluginCommandSpecs(provider?: string): Array<{ name: string; description: string; + descriptionLocalizations?: Record; acceptsArgs: boolean; }> { - return Array.from(pluginCommands.values()).map((cmd) => ({ - name: resolvePluginNativeName(cmd, provider), - description: cmd.description, - acceptsArgs: cmd.acceptsArgs ?? false, - })); + 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; + }); } diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 136d900f380..9ed993ee4b9 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -454,6 +454,35 @@ describe("registerPluginCommand", () => { }); }); + it("exposes native description localizations on plugin command specs", () => { + const result = registerVoiceCommandForTest({ + description: "Demo command", + descriptionLocalizations: { ko: "데모 명령" }, + }); + + expect(result).toEqual({ ok: true }); + expect(listProviderPluginCommandSpecs("discord")).toEqual([ + { + name: "voice", + description: "Demo command", + descriptionLocalizations: { ko: "데모 명령" }, + acceptsArgs: false, + }, + ]); + }); + + it("rejects empty native description localizations", () => { + const result = registerVoiceCommandForTest({ + description: "Demo command", + descriptionLocalizations: { ko: " " }, + }); + + expect(result).toEqual({ + ok: false, + error: 'Description localization "ko" cannot be empty', + }); + }); + it("rejects empty native progress metadata", () => { const result = registerVoiceCommandForTest({ nativeProgressMessages: { telegram: " " }, diff --git a/src/plugins/types.ts b/src/plugins/types.ts index e1f35e68dde..17a256e6c6d 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1963,6 +1963,8 @@ export type OpenClawPluginCommandDefinition = { }; /** Description shown in /help and command menus */ description: string; + /** Localized descriptions for native command surfaces that support them. */ + descriptionLocalizations?: Record; /** Optional system-prompt guidance for agents when this command is registered. */ agentPromptGuidance?: readonly string[]; /** Whether this command accepts arguments */