From 19354c9a6a95f6c7867f86e55f8f461030fd08e9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 20:02:59 -0400 Subject: [PATCH] fix(discord): keep slash follow-ups ephemeral (#69869) Merged via squash. Prepared head SHA: 0f5ab771565292ba4c8a532384224c04351aa048 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 4 + .../discord/src/monitor/native-command-ui.ts | 3 + .../native-command.command-arg.test.ts | 97 +++++++++++++++++++ .../native-command.status-direct.test.ts | 61 ++++++++++++ .../discord/src/monitor/native-command.ts | 29 ++++-- 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 extensions/discord/src/monitor/native-command.command-arg.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c56e675be3..6d7fccb759e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ Docs: https://docs.openclaw.ai - CLI/doctor plugins: lazy-load doctor plugin paths and prefer installed plugin `dist/*` runtime entries over source-adjacent JavaScript fallbacks, reducing the measured `doctor --non-interactive` runtime by about 74% while keeping cold doctor startup on built plugin artifacts. (#69840) Thanks @gumadeiras. - WhatsApp/groups+direct: forward per-group and per-direct `systemPrompt` config into inbound context `GroupSystemPrompt` so configured per-chat behavioral instructions are injected on every turn. Supports `"*"` wildcard fallback and account-scoped overrides under `channels.whatsapp.accounts..{groups,direct}`; account maps fully replace root maps (no deep merge), matching the existing `requireMention` pattern. Closes #7011. (#59553) Thanks @Bluetegu. +### Fixes + +- Discord: keep slash command follow-up chunks ephemeral when the command is configured for ephemeral replies, so long `/status` output no longer leaks fallback model or runtime details into the public channel. (#69869) thanks @gumadeiras. + ## 2026.4.21 ### Changes diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts index edabe1cbd7f..a969c7c1eaa 100644 --- a/extensions/discord/src/monitor/native-command-ui.ts +++ b/extensions/discord/src/monitor/native-command-ui.ts @@ -35,6 +35,7 @@ import { withTimeout, } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordChannelNameSafe } from "./channel-access.js"; +import { resolveDiscordSlashCommandConfig } from "./commands.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; import { readDiscordModelPickerRecentModels, @@ -80,6 +81,7 @@ export type DispatchDiscordCommandInteractionParams = { sessionPrefix: string; preferFollowUp: boolean; threadBindings: ThreadBindingManager; + responseEphemeral?: boolean; suppressReplies?: boolean; }; @@ -918,6 +920,7 @@ export async function handleDiscordCommandArgInteraction(params: { sessionPrefix: ctx.sessionPrefix, preferFollowUp: true, threadBindings: ctx.threadBindings, + responseEphemeral: resolveDiscordSlashCommandConfig(ctx.discordConfig?.slashCommand).ephemeral, }); } diff --git a/extensions/discord/src/monitor/native-command.command-arg.test.ts b/extensions/discord/src/monitor/native-command.command-arg.test.ts new file mode 100644 index 00000000000..4e7b13d2343 --- /dev/null +++ b/extensions/discord/src/monitor/native-command.command-arg.test.ts @@ -0,0 +1,97 @@ +import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth"; +import * as commandRegistryModule from "openclaw/plugin-sdk/command-auth"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createDiscordCommandArgFallbackButton, + type DispatchDiscordCommandInteraction, +} from "./native-command-ui.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.js"; + +type CommandArgContext = Parameters[0]["ctx"]; +type CommandArgButton = ReturnType; +type CommandArgInteraction = Parameters[0]; +type CommandArgData = Parameters[1]; + +function createCommandDefinition(): ChatCommandDefinition { + return { + key: "think", + nativeName: "think", + description: "Set thinking level", + textAliases: ["/think"], + acceptsArgs: true, + args: [ + { + name: "level", + description: "Thinking level", + type: "string", + required: true, + }, + ], + argsParsing: "none", + scope: "native", + }; +} + +function createContext( + discordConfig: NonNullable["discord"], +): CommandArgContext { + const cfg = { + channels: { + discord: discordConfig, + }, + } as OpenClawConfig; + return { + cfg, + discordConfig, + accountId: "default", + sessionPrefix: "discord:slash", + threadBindings: createNoopThreadBindingManager("default"), + }; +} + +function createInteraction(): CommandArgInteraction { + return { + user: { + id: "owner", + username: "tester", + globalName: "Tester", + }, + update: vi.fn().mockResolvedValue({ ok: true }), + } as unknown as CommandArgInteraction; +} + +async function safeInteractionCall(_label: string, fn: () => Promise): Promise { + return await fn(); +} + +describe("discord command argument fallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("preserves public slash command visibility for selected argument follow-ups", async () => { + const commandDefinition = createCommandDefinition(); + vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockReturnValue(commandDefinition); + const dispatchSpy = vi.fn().mockResolvedValue(); + const button = createDiscordCommandArgFallbackButton({ + ctx: createContext({ slashCommand: { ephemeral: false } }), + safeInteractionCall, + dispatchCommandInteraction: dispatchSpy, + }); + + await button.run(createInteraction(), { + command: "think", + arg: "level", + value: "high", + user: "owner", + } satisfies CommandArgData); + + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "/think high", + responseEphemeral: false, + }), + ); + }); +}); diff --git a/extensions/discord/src/monitor/native-command.status-direct.test.ts b/extensions/discord/src/monitor/native-command.status-direct.test.ts index 15cab978edf..bdb5cd46b23 100644 --- a/extensions/discord/src/monitor/native-command.status-direct.test.ts +++ b/extensions/discord/src/monitor/native-command.status-direct.test.ts @@ -6,6 +6,7 @@ import { createNoopThreadBindingManager } from "./thread-bindings.js"; const runtimeModuleMocks = vi.hoisted(() => ({ dispatchReplyWithDispatcher: vi.fn(), + loadWebMedia: vi.fn(), resolveDirectStatusReplyForSession: vi.fn(), })); @@ -25,6 +26,10 @@ vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({ runtimeModuleMocks.resolveDirectStatusReplyForSession(...args), })); +vi.mock("openclaw/plugin-sdk/web-media", () => ({ + loadWebMedia: (...args: unknown[]) => runtimeModuleMocks.loadWebMedia(...args), +})); + let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand; let discordNativeCommandTesting: typeof import("./native-command.js").__testing; @@ -134,6 +139,10 @@ describe("discord native /status", () => { runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({ text: "status reply", }); + runtimeModuleMocks.loadWebMedia.mockResolvedValue({ + buffer: Buffer.from("image"), + fileName: "status.png", + }); discordNativeCommandTesting.setDispatchReplyWithDispatcher( runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithDispatcher, ); @@ -152,11 +161,63 @@ describe("discord native /status", () => { expect(interaction.followUp).toHaveBeenCalledWith( expect.objectContaining({ content: "status reply", + ephemeral: true, }), ); expect(interaction.reply).not.toHaveBeenCalled(); }); + it("keeps every direct status chunk ephemeral", async () => { + runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({ + text: `fallback models\nruntime info\n${"x".repeat(2200)}`, + }); + const cfg = createConfig(); + const command = await createStatusCommand(cfg); + const interaction = createInteraction(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(interaction.followUp.mock.calls.length).toBeGreaterThan(1); + for (const [payload] of interaction.followUp.mock.calls) { + expect(payload).toEqual( + expect.objectContaining({ + ephemeral: true, + }), + ); + } + }); + + it("keeps direct status media follow-up chunks ephemeral", async () => { + runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({ + text: `status image\n${"x".repeat(2200)}`, + mediaUrls: ["https://example.com/status.png"], + }); + const cfg = createConfig(); + const command = await createStatusCommand(cfg); + const interaction = createInteraction(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(runtimeModuleMocks.loadWebMedia).toHaveBeenCalledWith("https://example.com/status.png", { + localRoots: expect.any(Array), + }); + expect(interaction.followUp.mock.calls.length).toBeGreaterThan(1); + expect(interaction.followUp.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + ephemeral: true, + files: expect.arrayContaining([expect.objectContaining({ name: "status.png" })]), + }), + ); + for (const [payload] of interaction.followUp.mock.calls) { + expect(payload).toEqual( + expect.objectContaining({ + ephemeral: true, + }), + ); + } + expect(interaction.reply).not.toHaveBeenCalled(); + }); + it("passes through the effective guild activation when requireMention is disabled", async () => { const cfg = createConfig({ requireMention: false }); const command = await createStatusCommand(cfg); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index d7711fdf000..c81f5f59a63 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -33,7 +33,6 @@ import { type ChatCommandDefinition, type CommandArgDefinition, type CommandArgValues, - type CommandArgs, type NativeCommandSpec, } from "openclaw/plugin-sdk/native-command-registry"; import * as pluginRuntime from "openclaw/plugin-sdk/plugin-runtime"; @@ -88,6 +87,10 @@ import type { ThreadBindingManager } from "./thread-bindings.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; +type DiscordCommandArgs = { + raw?: string; + values?: CommandArgValues; +}; const log = createSubsystemLogger("discord/native-command"); // Discord application command and option descriptions are limited to 1-100 chars. // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-structure @@ -559,7 +562,7 @@ async function resolveDiscordNativeAutocompleteAuthorized(params: { function readDiscordCommandArgs( interaction: CommandInteraction, definitions?: CommandArgDefinition[], -): CommandArgs | undefined { +): DiscordCommandArgs | undefined { if (!definitions || definitions.length === 0) { return undefined; } @@ -726,7 +729,7 @@ export function createDiscordNativeCommand(params: { ? ({ ...commandArgs, raw: serializeCommandArgs(commandDefinition, commandArgs) ?? commandArgs.raw, - } satisfies CommandArgs) + } satisfies DiscordCommandArgs) : undefined; const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw); await dispatchDiscordCommandInteraction({ @@ -742,6 +745,7 @@ export function createDiscordNativeCommand(params: { // follow-up/edit semantics instead of the initial reply endpoint. preferFollowUp: true, threadBindings, + responseEphemeral: ephemeralDefault, }); } })(); @@ -751,13 +755,14 @@ async function dispatchDiscordCommandInteraction(params: { interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; prompt: string; command: ChatCommandDefinition; - commandArgs?: CommandArgs; + commandArgs?: DiscordCommandArgs; cfg: ReturnType; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; preferFollowUp: boolean; threadBindings: ThreadBindingManager; + responseEphemeral?: boolean; suppressReplies?: boolean; }) { const { @@ -771,13 +776,15 @@ async function dispatchDiscordCommandInteraction(params: { sessionPrefix, preferFollowUp, threadBindings, + responseEphemeral, suppressReplies, } = params; const commandName = command.nativeName ?? command.key; const respond = async (content: string, options?: { ephemeral?: boolean }) => { + const ephemeral = options?.ephemeral ?? responseEphemeral; const payload = { content, - ...(options?.ephemeral !== undefined ? { ephemeral: options.ephemeral } : {}), + ...(ephemeral !== undefined ? { ephemeral } : {}), }; await safeDiscordInteractionCall("interaction reply", async () => { if (preferFollowUp) { @@ -1099,6 +1106,7 @@ async function dispatchDiscordCommandInteraction(params: { }), maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp, + responseEphemeral, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); return; @@ -1168,6 +1176,7 @@ async function dispatchDiscordCommandInteraction(params: { }), maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp, + responseEphemeral, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); return; @@ -1233,6 +1242,7 @@ async function dispatchDiscordCommandInteraction(params: { }), maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }), preferFollowUp: preferFollowUp || didReply, + responseEphemeral, chunkMode: resolveChunkMode(cfg, "discord", accountId), }); } catch (error) { @@ -1314,6 +1324,7 @@ async function deliverDiscordInteractionReply(params: { textLimit: number; maxLinesPerMessage?: number; preferFollowUp: boolean; + responseEphemeral?: boolean; chunkMode: "length" | "newline"; }) { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; @@ -1337,6 +1348,9 @@ async function deliverDiscordInteractionReply(params: { ? { content, ...(components ? { components } : {}), + ...(params.responseEphemeral !== undefined + ? { ephemeral: params.responseEphemeral } + : {}), files: files.map((file) => { if (file.data instanceof Blob) { return { name: file.name, data: file.data }; @@ -1348,6 +1362,9 @@ async function deliverDiscordInteractionReply(params: { : { content, ...(components ? { components } : {}), + ...(params.responseEphemeral !== undefined + ? { ephemeral: params.responseEphemeral } + : {}), }; await safeDiscordInteractionCall("interaction send", async () => { if (!preferFollowUp && !hasReplied) { @@ -1388,7 +1405,7 @@ async function deliverDiscordInteractionReply(params: { if (!chunk.trim()) { continue; } - await interaction.followUp({ content: chunk }); + await sendMessage(chunk); } return; }