fix(discord): harden slash command routing

This commit is contained in:
Shadow
2026-03-03 11:22:32 -06:00
parent 0eef7a367d
commit b8b1eeb052
8 changed files with 290 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Docs/tool-loop detection config keys: align `docs/tools/loop-detection.md` examples and field names with the current `tools.loopDetection` schema to prevent copy-paste validation failures from outdated keys. (#33182) Thanks @Mylszd.
- Discord/inbound debouncer: skip bot-own MESSAGE_CREATE events before they reach the debounce queue to avoid self-triggered slowdowns in busy servers. Thanks @thewilloftheshadow.
- Discord/Agent-scoped media roots: pass `mediaLocalRoots` through Discord monitor reply delivery (message + component interaction paths) so local media attachments honor per-agent workspace roots instead of falling back to default global roots. Thanks @thewilloftheshadow.
- Discord/slash command handling: intercept text-based slash commands in channels, register plugin commands as native, and send fallback acknowledgments for empty slash runs so interactions do not hang. Thanks @thewilloftheshadow.
- Discord/presence defaults: send an online presence update on ready when no custom presence is configured so bots no longer appear offline by default. Thanks @thewilloftheshadow.
- Discord/typing cleanup: stop typing indicators after silent/NO_REPLY runs by marking the run complete before dispatch idle cleanup. Thanks @thewilloftheshadow.
- Discord/voice messages: request upload slots with JSON fetch calls so voice message uploads no longer fail with content-type errors. Thanks @thewilloftheshadow.

View File

@@ -103,7 +103,11 @@ export function createInboundDebouncer<T>(params: InboundDebounceCreateParams<T>
if (key && buffers.has(key)) {
await flushKey(key);
}
await params.onFlush([item]);
try {
await params.onFlush([item]);
} catch (err) {
params.onError?.(err, [item]);
}
return;
}

View File

@@ -263,6 +263,14 @@ export async function preflightDiscordMessage(
const messageText = resolveDiscordMessageText(message, {
includeForwarded: true,
});
// Intercept text-only slash commands (e.g. user typing "/reset" instead of using Discord's slash command picker)
// These should not be forwarded to the agent; proper slash command interactions are handled elsewhere
if (!isDirectMessage && baseText && hasControlCommand(baseText, params.cfg)) {
logVerbose(`discord: drop text-based slash command ${message.id} (intercepted at gateway)`);
return null;
}
recordChannelActivity({
channel: "discord",
accountId: params.accountId,

View File

@@ -0,0 +1,113 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as pluginCommandsModule from "../../plugins/commands.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type MockCommandInteraction = {
user: { id: string; username: string; globalName: string };
channel: { type: ChannelType; id: string };
guild: null;
rawData: { id: string; member: { roles: string[] } };
options: {
getString: ReturnType<typeof vi.fn>;
getNumber: ReturnType<typeof vi.fn>;
getBoolean: ReturnType<typeof vi.fn>;
};
reply: ReturnType<typeof vi.fn>;
followUp: ReturnType<typeof vi.fn>;
client: object;
};
function createInteraction(): MockCommandInteraction {
return {
user: {
id: "owner",
username: "tester",
globalName: "Tester",
},
channel: {
type: ChannelType.DM,
id: "dm-1",
},
guild: null,
rawData: {
id: "interaction-1",
member: { roles: [] },
},
options: {
getString: vi.fn().mockReturnValue(null),
getNumber: vi.fn().mockReturnValue(null),
getBoolean: vi.fn().mockReturnValue(null),
},
reply: vi.fn().mockResolvedValue({ ok: true }),
followUp: vi.fn().mockResolvedValue({ ok: true }),
client: {},
};
}
function createConfig(): OpenClawConfig {
return {
channels: {
discord: {
dm: { enabled: true, policy: "open" },
},
},
} as OpenClawConfig;
}
describe("Discord native plugin command dispatch", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("executes matched plugin commands directly without invoking the agent dispatcher", async () => {
const cfg = createConfig();
const commandSpec: NativeCommandSpec = {
name: "cron_jobs",
description: "List cron jobs",
acceptsArgs: false,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const interaction = createInteraction();
const pluginMatch = {
command: {
name: "cron_jobs",
description: "List cron jobs",
pluginId: "cron-jobs",
acceptsArgs: false,
handler: vi.fn().mockResolvedValue({ text: "jobs" }),
},
args: undefined,
};
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(
pluginMatch as ReturnType<typeof pluginCommandsModule.matchPluginCommand>,
);
const executeSpy = vi
.spyOn(pluginCommandsModule, "executePluginCommand")
.mockResolvedValue({ text: "direct plugin output" });
const dispatchSpy = vi
.spyOn(dispatcherModule, "dispatchReplyWithDispatcher")
.mockResolvedValue({} as never);
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(executeSpy).toHaveBeenCalledTimes(1);
expect(dispatchSpy).not.toHaveBeenCalled();
expect(interaction.reply).toHaveBeenCalledWith(
expect.objectContaining({ content: "direct plugin output" }),
);
});
});

View File

@@ -46,6 +46,7 @@ import { logVerbose } from "../../globals.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
@@ -215,6 +216,19 @@ function isDiscordUnknownInteraction(error: unknown): boolean {
return false;
}
function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
if ((payload.text ?? "").trim()) {
return true;
}
if ((payload.mediaUrl ?? "").trim()) {
return true;
}
if (payload.mediaUrls?.some((entry) => entry.trim())) {
return true;
}
return false;
}
async function safeDiscordInteractionCall<T>(
label: string,
fn: () => Promise<T>,
@@ -1455,6 +1469,46 @@ async function dispatchDiscordCommandInteraction(params: {
return;
}
const pluginMatch = matchPluginCommand(prompt);
if (pluginMatch) {
if (suppressReplies) {
return;
}
const channelId = rawChannelId || "unknown";
const pluginReply = await executePluginCommand({
command: pluginMatch.command,
args: pluginMatch.args,
senderId: sender.id,
channel: "discord",
channelId,
isAuthorizedSender: commandAuthorized,
commandBody: prompt,
config: cfg,
from: isDirectMessage
? `discord:${user.id}`
: isGroupDm
? `discord:group:${channelId}`
: `discord:channel:${channelId}`,
to: `slash:${user.id}`,
accountId,
});
if (!hasRenderableReplyPayload(pluginReply)) {
await respond("Done.");
return;
}
await deliverDiscordInteractionReply({
interaction,
payload: pluginReply,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp,
chunkMode: resolveChunkMode(cfg, "discord", accountId),
});
return;
}
const pickerCommandContext = shouldOpenDiscordModelPickerFromCommand({
command,
commandArgs,
@@ -1571,7 +1625,7 @@ async function dispatchDiscordCommandInteraction(params: {
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
let didReply = false;
await dispatchReplyWithDispatcher({
const dispatchResult = await dispatchReplyWithDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {
@@ -1616,6 +1670,29 @@ async function dispatchDiscordCommandInteraction(params: {
onModelSelected,
},
});
// Fallback: if the agent turn produced no deliverable replies (for example,
// a skill only used message.send side effects), close the interaction with
// a minimal acknowledgment so Discord does not stay in a pending state.
if (
!suppressReplies &&
!didReply &&
dispatchResult.counts.final === 0 &&
dispatchResult.counts.block === 0 &&
dispatchResult.counts.tool === 0
) {
await safeDiscordInteractionCall("interaction empty fallback", async () => {
const payload = {
content: "✅ Done.",
ephemeral: true,
};
if (preferFollowUp) {
await interaction.followUp(payload);
return;
}
await interaction.reply(payload);
});
}
}
async function deliverDiscordInteractionReply(params: {

View File

@@ -3,6 +3,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
type NativeCommandSpecMock = {
name: string;
description: string;
acceptsArgs: boolean;
};
type PluginCommandSpecMock = {
name: string;
description: string;
acceptsArgs: boolean;
};
const {
clientFetchUserMock,
clientGetPluginMock,
@@ -13,6 +25,7 @@ const {
createThreadBindingManagerMock,
reconcileAcpThreadBindingsOnStartupMock,
createdBindingManagers,
getPluginCommandSpecsMock,
listNativeCommandSpecsForConfigMock,
listSkillCommandsForAgentsMock,
monitorLifecycleMock,
@@ -50,7 +63,10 @@ const {
staleSessionKeys: [],
})),
createdBindingManagers,
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
getPluginCommandSpecsMock: vi.fn<() => PluginCommandSpecMock[]>(() => []),
listNativeCommandSpecsForConfigMock: vi.fn<() => NativeCommandSpecMock[]>(() => [
{ name: "cmd", description: "built-in", acceptsArgs: false },
]),
listSkillCommandsForAgentsMock: vi.fn(() => []),
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
params.threadBindings.stop();
@@ -148,6 +164,10 @@ vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
}));
vi.mock("../../plugins/commands.js", () => ({
getPluginCommandSpecs: getPluginCommandSpecsMock,
}));
vi.mock("../../runtime.js", () => ({
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
}));
@@ -298,7 +318,10 @@ describe("monitorDiscordProvider", () => {
staleSessionKeys: [],
});
createdBindingManagers.length = 0;
listNativeCommandSpecsForConfigMock.mockClear().mockReturnValue([{ name: "cmd" }]);
getPluginCommandSpecsMock.mockClear().mockReturnValue([]);
listNativeCommandSpecsForConfigMock
.mockClear()
.mockReturnValue([{ name: "cmd", description: "built-in", acceptsArgs: false }]);
listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]);
monitorLifecycleMock.mockClear().mockImplementation(async (params) => {
params.threadBindings.stop();
@@ -405,6 +428,27 @@ describe("monitorDiscordProvider", () => {
expect(eventQueue?.listenerTimeout).toBe(300_000);
});
it("registers plugin commands as native Discord commands", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
listNativeCommandSpecsForConfigMock.mockReturnValue([
{ name: "cmd", description: "built-in", acceptsArgs: false },
]);
getPluginCommandSpecsMock.mockReturnValue([
{ name: "cron_jobs", description: "List cron jobs", acceptsArgs: false },
]);
await monitorDiscordProvider({
config: baseConfig(),
runtime: baseRuntime(),
});
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(commandNames).toContain("cmd");
expect(commandNames).toContain("cron_jobs");
});
it("reports connected status on startup and shutdown", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
const setStatus = vi.fn();

View File

@@ -11,6 +11,7 @@ import { GatewayCloseCodes, type GatewayPlugin } from "@buape/carbon/gateway";
import { VoicePlugin } from "@buape/carbon/voice";
import { Routes } from "discord-api-types/v10";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
@@ -37,6 +38,7 @@ import { danger, logVerbose, shouldLogVerbose, warn } from "../../globals.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getPluginCommandSpecs } from "../../plugins/commands.js";
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
import { resolveDiscordAccount } from "../accounts.js";
import { fetchDiscordApplicationId } from "../probe.js";
@@ -141,6 +143,37 @@ function dedupeSkillCommandsForDiscord(
return deduped;
}
function appendPluginCommandSpecs(params: {
commandSpecs: NativeCommandSpec[];
runtime: RuntimeEnv;
}): NativeCommandSpec[] {
const merged = [...params.commandSpecs];
const existingNames = new Set(
merged.map((spec) => spec.name.trim().toLowerCase()).filter(Boolean),
);
for (const pluginCommand of getPluginCommandSpecs()) {
const normalizedName = pluginCommand.name.trim().toLowerCase();
if (!normalizedName) {
continue;
}
if (existingNames.has(normalizedName)) {
params.runtime.error?.(
danger(
`discord: plugin command "/${normalizedName}" duplicates an existing native command. Skipping.`,
),
);
continue;
}
existingNames.add(normalizedName);
merged.push({
name: pluginCommand.name,
description: pluginCommand.description,
acceptsArgs: pluginCommand.acceptsArgs,
});
}
return merged;
}
async function deployDiscordCommands(params: {
client: Client;
runtime: RuntimeEnv;
@@ -317,10 +350,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
let commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, { skillCommands, provider: "discord" })
: [];
if (nativeEnabled) {
commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime });
}
const initialCommandCount = commandSpecs.length;
if (nativeEnabled && nativeSkillsEnabled && commandSpecs.length > maxDiscordCommands) {
skillCommands = [];
commandSpecs = listNativeCommandSpecsForConfig(cfg, { skillCommands: [], provider: "discord" });
commandSpecs = appendPluginCommandSpecs({ commandSpecs, runtime });
runtime.log?.(
warn(
`discord: ${initialCommandCount} commands exceeds limit; removing per-skill commands and keeping /skill.`,

View File

@@ -322,9 +322,11 @@ export function listPluginCommands(): Array<{
export function getPluginCommandSpecs(): Array<{
name: string;
description: string;
acceptsArgs: boolean;
}> {
return Array.from(pluginCommands.values()).map((cmd) => ({
name: cmd.name,
description: cmd.description,
acceptsArgs: cmd.acceptsArgs ?? false,
}));
}