diff --git a/CHANGELOG.md b/CHANGELOG.md index 5392265ae..9a8b3b0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/auto-reply/inbound-debounce.ts b/src/auto-reply/inbound-debounce.ts index 5dc26a6b4..940732800 100644 --- a/src/auto-reply/inbound-debounce.ts +++ b/src/auto-reply/inbound-debounce.ts @@ -103,7 +103,11 @@ export function createInboundDebouncer(params: InboundDebounceCreateParams if (key && buffers.has(key)) { await flushKey(key); } - await params.onFlush([item]); + try { + await params.onFlush([item]); + } catch (err) { + params.onError?.(err, [item]); + } return; } diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 48af44d98..da1b14050 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -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, diff --git a/src/discord/monitor/native-command.plugin-dispatch.test.ts b/src/discord/monitor/native-command.plugin-dispatch.test.ts new file mode 100644 index 000000000..47de666d3 --- /dev/null +++ b/src/discord/monitor/native-command.plugin-dispatch.test.ts @@ -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; + getNumber: ReturnType; + getBoolean: ReturnType; + }; + reply: ReturnType; + followUp: ReturnType; + 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, + ); + 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 }).run(interaction as unknown); + + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: "direct plugin output" }), + ); + }); +}); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 8960f6164..79eda2d97 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -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( label: string, fn: () => Promise, @@ -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: { diff --git a/src/discord/monitor/provider.test.ts b/src/discord/monitor/provider.test.ts index 74a0ad51d..351661a8a 100644 --- a/src/discord/monitor/provider.test.ts +++ b/src/discord/monitor/provider.test.ts @@ -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) + .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(); diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index b9a5599c8..bbfda2202 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -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.`, diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index dfe3522dc..469a4c015 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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, })); }