fix(discord): send component-only native replies

This commit is contained in:
Vincent Koc
2026-05-01 03:52:10 -07:00
parent c7a91f9632
commit 6dac51569e
3 changed files with 76 additions and 3 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Discord/voice: merge configured media-understanding providers such as Deepgram into partial active provider registries, so follow-up voice turns keep transcribing after another media plugin is already active. Fixes #65687. Thanks @OneMintJulep.
- WhatsApp: stage `qrcode` through root mirrored runtime dependencies so packaged QR pairing can render from staged plugin-runtime-deps installs. Fixes #75394. Thanks @FelipeX2001.
- Discord/voice: apply per-channel Discord `systemPrompt` overrides to voice transcript turns by forwarding the trusted channel prompt through the voice agent run. Fixes #47095. Thanks @qearlyao.
- Discord/native commands: send component-only interaction replies from slash command and status handlers instead of treating renderable Discord components as an empty response. Thanks @vincentkoc.
- Gateway/agent: reject strict `openclaw agent --deliver` requests with missing delivery targets before starting the agent run, so users do not wait for a completed turn that cannot send anywhere. Thanks @vincentkoc.
- Setup/import: honor non-interactive `--import-from` onboarding flags by running the migration import path instead of silently completing normal setup without importing anything. Thanks @vincentkoc.
- Discord/voice: run voice-channel turns under a voice-output policy that hides the agent `tts` tool and asks for spoken reply text, so `/vc join` sessions synthesize and play agent replies instead of ending with `NO_REPLY`. Fixes #61536. Thanks @aounakram.

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from "vitest";
import { Container, TextDisplay } from "../internal/discord.js";
import {
deliverDiscordInteractionReply,
hasRenderableReplyPayload,
} from "./native-command-reply.js";
function createInteraction() {
return {
reply: vi.fn().mockResolvedValue({ ok: true }),
followUp: vi.fn().mockResolvedValue({ ok: true }),
};
}
describe("deliverDiscordInteractionReply", () => {
it("sends component-only native command replies as follow-ups", async () => {
const interaction = createInteraction();
const components = [new Container([new TextDisplay("Pick a model")])];
const payload = {
channelData: {
discord: {
components,
},
},
};
expect(hasRenderableReplyPayload(payload)).toBe(true);
await deliverDiscordInteractionReply({
interaction: interaction as never,
payload,
textLimit: 2000,
preferFollowUp: true,
responseEphemeral: true,
chunkMode: "length",
});
expect(interaction.followUp).toHaveBeenCalledWith({
components,
ephemeral: true,
});
expect(interaction.reply).not.toHaveBeenCalled();
});
it("sends component-only native command replies through the initial reply when not deferred", async () => {
const interaction = createInteraction();
const components = [new Container([new TextDisplay("Choose an action")])];
await deliverDiscordInteractionReply({
interaction: interaction as never,
payload: {
channelData: {
discord: {
components,
},
},
},
textLimit: 2000,
preferFollowUp: false,
chunkMode: "length",
});
expect(interaction.reply).toHaveBeenCalledWith({
components,
});
expect(interaction.followUp).not.toHaveBeenCalled();
});
});

View File

@@ -89,10 +89,11 @@ export async function deliverDiscordInteractionReply(params: {
files?: { name: string; data: Buffer }[],
components?: TopLevelComponents[],
) => {
const contentPayload = content ? { content } : {};
const payload =
files && files.length > 0
? {
content,
...contentPayload,
...(components ? { components } : {}),
...(params.responseEphemeral !== undefined
? { ephemeral: params.responseEphemeral }
@@ -106,7 +107,7 @@ export async function deliverDiscordInteractionReply(params: {
}),
}
: {
content,
...contentPayload,
...(components ? { components } : {}),
...(params.responseEphemeral !== undefined
? { ephemeral: params.responseEphemeral }
@@ -159,7 +160,7 @@ export async function deliverDiscordInteractionReply(params: {
if (!reply.hasText && !firstMessageComponents) {
return;
}
const chunks =
let chunks =
reply.text || firstMessageComponents
? resolveTextChunksWithFallback(
reply.text,
@@ -170,6 +171,9 @@ export async function deliverDiscordInteractionReply(params: {
}),
)
: [];
if (chunks.length === 0 && firstMessageComponents) {
chunks = [""];
}
for (const chunk of chunks) {
if (!chunk.trim() && !firstMessageComponents) {
continue;