From fa98d01aa1fc0b6e0e7c5720b8d16c2b38b13233 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 3 May 2026 11:02:54 -0700 Subject: [PATCH] fix(discord): skip disabled native command cleanup --- CHANGELOG.md | 1 + docs/channels/discord.md | 2 +- docs/gateway/config-channels.md | 2 +- docs/tools/slash-commands.md | 2 +- .../discord/src/monitor/provider.test.ts | 30 +++++++++++++++++ extensions/discord/src/monitor/provider.ts | 32 +------------------ .../src/test-support/provider.test-support.ts | 6 +++- 7 files changed, 40 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb20d465ec4..8e5ee04cb23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Discord/native commands: skip slash-command registration and cleanup REST calls when `channels.discord.commands.native=false`, letting low-power gateways start without waiting on disabled native-command lifecycle requests. Fixes #76202. Thanks @vincentkoc. - Plugins/commands: normalize empty plugin command handler results and let Telegram native plugin commands send the empty-response fallback instead of throwing when a handler returns `undefined`. Fixes #74800. Thanks @vincentkoc. - Plugins/OpenRouter: advertise DeepSeek V4 thinking levels, including `xhigh` and `max`, through the runtime and lightweight provider policy surfaces so `/think` validation no longer rejects OpenRouter-routed DeepSeek V4 models. Fixes #74788. Thanks @vincentkoc. - Status/sessions: ignore malformed non-string persisted session provider/model metadata instead of throwing while rendering status summaries. Thanks @vincentkoc. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index ba95ef8376f..c73b4e1c9e7 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -622,7 +622,7 @@ Use `bindings[].match.roles` to route Discord guild members to different agents - `commands.native` defaults to `"auto"` and is enabled for Discord. - Per-channel override: `channels.discord.commands.native`. -- `commands.native=false` explicitly clears previously registered Discord native commands. +- `commands.native=false` skips Discord slash-command registration and cleanup during startup. Previously registered commands may remain visible in Discord until you remove them from the Discord app. - Native command auth uses the same Discord allowlists/policies as normal message handling. - Commands may still be visible in Discord UI for users who are not authorized; execution still enforces OpenClaw auth and returns "not authorized". diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index 29a5a010237..8a9feea8ff8 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -884,7 +884,7 @@ Include your own number in `allowFrom` to enable self-chat mode (ignores native - Text commands must be **standalone** messages with leading `/`. - `native: "auto"` turns on native commands for Discord/Telegram, leaves Slack off. - `nativeSkills: "auto"` turns on native skill commands for Discord/Telegram, leaves Slack off. -- Override per channel: `channels.discord.commands.native` (bool or `"auto"`). `false` clears previously registered commands. +- Override per channel: `channels.discord.commands.native` (bool or `"auto"`). For Discord, `false` skips native command registration and cleanup during startup. - Override native skill registration per channel with `channels..commands.nativeSkills`. - `channels.telegram.customCommands` adds extra Telegram bot menu entries. - `bash: true` enables `! ` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.`. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 5503e4a80e0..1b7e06b0388 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -65,7 +65,7 @@ There are two related systems: Enables parsing `/...` in chat messages. On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/Microsoft Teams), text commands still work even if you set this to `false`. - 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. + 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"`). On Discord, `false` skips slash-command registration and cleanup during startup; previously registered commands may remain visible until you remove them from the Discord app. 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. diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index d8200ec6e5e..f87bb2d66ec 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -24,6 +24,7 @@ const { createThreadBindingManagerMock, getAcpSessionStatusMock, getPluginCommandSpecsMock, + isNativeCommandsExplicitlyDisabledMock, isVerboseMock, listNativeCommandSpecsForConfigMock, listSkillCommandsForAgentsMock, @@ -992,6 +993,35 @@ describe("monitorDiscordProvider", () => { expect(getConstructedClientOptions().eventQueue?.listenerTimeout).toBe(120_000); }); + it("skips slash-command lifecycle REST when native commands are disabled", async () => { + const runtime = baseRuntime(); + isNativeCommandsExplicitlyDisabledMock.mockReturnValue(true); + resolveNativeCommandsEnabledMock.mockReturnValue(false); + resolveDiscordAccountMock.mockReturnValue({ + accountId: "default", + token: "MTIz.abc.def", + config: { + applicationId: "987654321098765432", + commands: { native: false, nativeSkills: false }, + voice: { enabled: false }, + agentComponents: { enabled: false }, + execApprovals: { enabled: false }, + }, + }); + + await monitorDiscordProvider({ + config: baseConfig(), + runtime, + }); + + expect(listNativeCommandSpecsForConfigMock).not.toHaveBeenCalled(); + expect(getPluginCommandSpecsMock).not.toHaveBeenCalled(); + expect(clientDeployCommandsMock).not.toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith( + expect.stringContaining("cleared native commands"), + ); + }); + it("derives application id from token before probing Discord over REST", async () => { const fetchApplicationId = vi.fn(async () => "network-app"); providerTesting.setFetchDiscordApplicationId(fetchApplicationId); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 28f0ec2c474..862e126f18f 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -6,7 +6,6 @@ import { import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types"; import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime"; import { - isNativeCommandsExplicitlyDisabled, resolveNativeCommandsEnabled, resolveNativeSkillsEnabled, } from "openclaw/plugin-sdk/native-command-config-runtime"; @@ -52,10 +51,7 @@ import { formatDiscordDeployErrorDetails, formatDiscordDeployErrorMessage, } from "./provider.deploy-errors.js"; -import { - clearDiscordNativeCommands, - runDiscordCommandDeployInBackground, -} from "./provider.deploy.js"; +import { runDiscordCommandDeployInBackground } from "./provider.deploy.js"; import { createDiscordProviderInteractionSurface } from "./provider.interactions.js"; import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js"; import { logDiscordStartupPhase as logDiscordStartupPhaseBase } from "./provider.startup-log.js"; @@ -275,10 +271,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { providerSetting: discordCfg.commands?.nativeSkills, globalSetting: cfg.commands?.nativeSkills, }); - const nativeDisabledExplicit = isNativeCommandsExplicitlyDisabled({ - providerSetting: discordCfg.commands?.native, - globalSetting: cfg.commands?.native, - }); const useAccessGroups = cfg.commands?.useAccessGroups !== false; const slashCommand = resolveDiscordSlashCommandConfig(discordCfg.slashCommand); const sessionPrefix = "discord:slash"; @@ -505,28 +497,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }); let voiceManager: DiscordVoiceManager | null = null; - if (nativeDisabledExplicit) { - logDiscordStartupPhase({ - runtime, - accountId: account.accountId, - phase: "clear-native-commands:start", - startAt: startupStartedAt, - gateway: lifecycleGateway, - }); - await clearDiscordNativeCommands({ - client, - applicationId, - runtime, - }); - logDiscordStartupPhase({ - runtime, - accountId: account.accountId, - phase: "clear-native-commands:done", - startAt: startupStartedAt, - gateway: lifecycleGateway, - }); - } - if (voiceEnabled) { const { DiscordVoiceManager, DiscordVoiceReadyListener, DiscordVoiceResumedListener } = await loadDiscordVoiceRuntime(); diff --git a/extensions/discord/src/test-support/provider.test-support.ts b/extensions/discord/src/test-support/provider.test-support.ts index 985a32429b7..e1d5c5d58a2 100644 --- a/extensions/discord/src/test-support/provider.test-support.ts +++ b/extensions/discord/src/test-support/provider.test-support.ts @@ -58,6 +58,7 @@ type ProviderMonitorTestMocks = { (params?: { cfg?: unknown; accountId?: string | null; token?: string | null }) => unknown >; resolveDiscordAllowlistConfigMock: Mock<() => Promise>; + isNativeCommandsExplicitlyDisabledMock: Mock<(params?: unknown) => boolean>; resolveNativeCommandsEnabledMock: Mock<(params?: unknown) => boolean>; resolveNativeSkillsEnabledMock: Mock<(params?: unknown) => boolean>; isVerboseMock: Mock<() => boolean>; @@ -150,6 +151,7 @@ const providerMonitorTestMocks: ProviderMonitorTestMocks = vi.hoisted(() => { guildEntries: undefined, allowFrom: undefined, })), + isNativeCommandsExplicitlyDisabledMock: vi.fn((_params) => false), resolveNativeCommandsEnabledMock: vi.fn((_params) => true), resolveNativeSkillsEnabledMock: vi.fn((_params) => false), isVerboseMock, @@ -183,6 +185,7 @@ const { monitorLifecycleMock, resolveDiscordAccountMock, resolveDiscordAllowlistConfigMock, + isNativeCommandsExplicitlyDisabledMock, resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabledMock, isVerboseMock, @@ -259,6 +262,7 @@ export function resetDiscordProviderMonitorMocks(params?: { guildEntries: undefined, allowFrom: undefined, }); + isNativeCommandsExplicitlyDisabledMock.mockClear().mockReturnValue(false); resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true); resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false); isVerboseMock.mockClear().mockReturnValue(false); @@ -387,7 +391,7 @@ vi.mock("openclaw/plugin-sdk/native-command-config-runtime", async () => { >("openclaw/plugin-sdk/native-command-config-runtime"); return { ...actual, - isNativeCommandsExplicitlyDisabled: () => false, + isNativeCommandsExplicitlyDisabled: isNativeCommandsExplicitlyDisabledMock, resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock, resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock, };