fix(discord): harden slash command routing
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
113
src/discord/monitor/native-command.plugin-dispatch.test.ts
Normal file
113
src/discord/monitor/native-command.plugin-dispatch.test.ts
Normal 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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user