From fbe659526667837359258ef68ad82219ee010030 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 21 Apr 2026 19:45:38 -0400 Subject: [PATCH] fix: keep discord media follow-ups ephemeral --- CHANGELOG.md | 2 +- .../native-command.status-direct.test.ts | 40 +++++++++++++++++++ .../discord/src/monitor/native-command.ts | 16 ++++---- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d090f6b9a8..6d7fccb759e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Docs: https://docs.openclaw.ai ### 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. +- 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 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 379116b91c9..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, ); @@ -178,6 +187,37 @@ describe("discord native /status", () => { } }); + 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 bacde6bf921..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({ @@ -752,7 +755,7 @@ async function dispatchDiscordCommandInteraction(params: { interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction; prompt: string; command: ChatCommandDefinition; - commandArgs?: CommandArgs; + commandArgs?: DiscordCommandArgs; cfg: ReturnType; discordConfig: DiscordConfig; accountId: string; @@ -1402,10 +1405,7 @@ async function deliverDiscordInteractionReply(params: { if (!chunk.trim()) { continue; } - await interaction.followUp({ - content: chunk, - ...(params.responseEphemeral !== undefined ? { ephemeral: params.responseEphemeral } : {}), - }); + await sendMessage(chunk); } return; }