diff --git a/CHANGELOG.md b/CHANGELOG.md index f865e3e2bc4..da52a46ae7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/discord/src/monitor/native-command-reply.test.ts b/extensions/discord/src/monitor/native-command-reply.test.ts new file mode 100644 index 00000000000..4243867ba00 --- /dev/null +++ b/extensions/discord/src/monitor/native-command-reply.test.ts @@ -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(); + }); +}); diff --git a/extensions/discord/src/monitor/native-command-reply.ts b/extensions/discord/src/monitor/native-command-reply.ts index 4f82c00c8f3..f3f666453de 100644 --- a/extensions/discord/src/monitor/native-command-reply.ts +++ b/extensions/discord/src/monitor/native-command-reply.ts @@ -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;