fix(discord): skip disabled native command cleanup

This commit is contained in:
Vincent Koc
2026-05-03 11:02:54 -07:00
parent 5a028489b1
commit fa98d01aa1
7 changed files with 40 additions and 35 deletions

View File

@@ -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.

View File

@@ -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".

View File

@@ -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.<provider>.commands.nativeSkills`.
- `channels.telegram.customCommands` adds extra Telegram bot menu entries.
- `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.

View File

@@ -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`.
</ParamField>
<ParamField path="commands.native" type='boolean | "auto"' default='"auto"'>
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.
</ParamField>
On Discord, native command specs may include `descriptionLocalizations`, which OpenClaw publishes as Discord `description_localizations` and includes in reconcile comparisons.
<ParamField path="commands.nativeSkills" type='boolean | "auto"' default='"auto"'>

View File

@@ -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);

View File

@@ -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();

View File

@@ -58,6 +58,7 @@ type ProviderMonitorTestMocks = {
(params?: { cfg?: unknown; accountId?: string | null; token?: string | null }) => unknown
>;
resolveDiscordAllowlistConfigMock: Mock<() => Promise<unknown>>;
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,
};