fix(discord): keep slash follow-ups ephemeral (#69869)

Merged via squash.

Prepared head SHA: 0f5ab77156
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-21 20:02:59 -04:00
committed by GitHub
parent 08bc16853e
commit 19354c9a6a
5 changed files with 188 additions and 6 deletions

View File

@@ -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.<id>.{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

View File

@@ -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,
});
}

View File

@@ -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<typeof createDiscordCommandArgFallbackButton>[0]["ctx"];
type CommandArgButton = ReturnType<typeof createDiscordCommandArgFallbackButton>;
type CommandArgInteraction = Parameters<CommandArgButton["run"]>[0];
type CommandArgData = Parameters<CommandArgButton["run"]>[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<OpenClawConfig["channels"]>["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<T>(_label: string, fn: () => Promise<T>): Promise<T | null> {
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<DispatchDiscordCommandInteraction>().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,
}),
);
});
});

View File

@@ -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<void> }).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<void> }).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);

View File

@@ -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<OpenClawConfig["channels"]>["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<typeof loadConfig>;
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;
}