From 1475b67111518fdd239e55d38ff99c4694e7bd66 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 05:01:17 -0700 Subject: [PATCH] fix(discord): deliver embed-only and component-only messages (#128152) --- config/assertion-safety-baseline.txt | 2 +- .../discord/src/actions/handle-action.test.ts | 22 +++ .../discord/src/actions/handle-action.ts | 6 +- .../src/actions/runtime.messaging.send.ts | 10 +- .../discord/src/actions/runtime.test.ts | 12 ++ extensions/discord/src/internal/payload.ts | 29 ++- .../src/monitor/native-command-reply.test.ts | 77 ++++++++ .../src/monitor/native-command-reply.ts | 109 +++++------ .../native-command.status-direct.test.ts | 19 ++ .../discord/src/send.message-request.test.ts | 65 ++++++ .../discord/src/send.message-request.ts | 16 +- extensions/discord/src/send.outbound.ts | 3 +- .../send.sends-basic-channel-messages.test.ts | 185 +++++++++++++++++- extensions/discord/src/send.shared.ts | 25 +-- 14 files changed, 477 insertions(+), 103 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index c5ef0bb1cb0d..df11e765c3fb 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -402,7 +402,7 @@ extensions/discord/src/monitor/model-picker-preferences-migrations.ts 5 extensions/discord/src/monitor/model-picker-preferences.ts 1 extensions/discord/src/monitor/model-picker.state.ts 3 extensions/discord/src/monitor/native-command-model-picker-interaction.ts 1 -extensions/discord/src/monitor/native-command-reply.ts 3 +extensions/discord/src/monitor/native-command-reply.ts 2 extensions/discord/src/monitor/native-command.ts 2 extensions/discord/src/monitor/presence.ts 1 extensions/discord/src/monitor/provider.allowlist.ts 6 diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts index 11b1694413e6..2957fb5bf9d7 100644 --- a/extensions/discord/src/actions/handle-action.test.ts +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -863,6 +863,28 @@ describe("handleDiscordMessageAction", () => { }); }); + it("forwards embed-only Discord sends without requiring message text", async () => { + const embeds = [{ title: "Release notes", description: "Version available" }]; + const cfg = discordConfig(); + + await handleDiscordMessageAction({ + action: "send", + params: { to: "channel:123", embeds }, + cfg, + }); + + expect(handleDiscordActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sendMessage", + to: "channel:123", + content: "", + embeds, + }), + cfg, + defaultActionOptions(), + ); + }); + it("downgrades chart-only presentations to Discord component text", async () => { const cfg = discordConfig(); diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index 2f673046f9ba..8a64af750771 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -223,8 +223,10 @@ export async function handleDiscordMessageAction( Boolean(rawComponents) && (typeof rawComponents === "function" || typeof rawComponents === "object"); const components = hasComponents ? rawComponents : undefined; + const rawEmbeds = params.embeds; + const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const content = readStringParam(params, "message", { - required: !asVoice && !hasComponents && !mediaUrl && !presentationFellBack, + required: !asVoice && !hasComponents && !embeds?.length && !mediaUrl && !presentationFellBack, allowEmpty: true, }); const deliveryContent = @@ -236,8 +238,6 @@ export async function handleDiscordMessageAction( : content; const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); - const rawEmbeds = params.embeds; - const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const silent = readBooleanParam(params, "silent") === true; const suppressEmbeds = readBooleanParam(params, "suppressEmbeds"); const sessionKey = readStringParam(params, "__sessionKey"); diff --git a/extensions/discord/src/actions/runtime.messaging.send.ts b/extensions/discord/src/actions/runtime.messaging.send.ts index 9fe47a5d1e57..bd5d8702cf36 100644 --- a/extensions/discord/src/actions/runtime.messaging.send.ts +++ b/extensions/discord/src/actions/runtime.messaging.send.ts @@ -213,17 +213,17 @@ export async function handleDiscordMessageSendAction(ctx: DiscordMessagingAction readStringParam(ctx.params, "mediaUrl", { trim: false }) ?? readStringParam(ctx.params, "path", { trim: false }) ?? readStringParam(ctx.params, "filePath", { trim: false }); + const rawEmbeds = ctx.params.embeds; + const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) + ? (rawEmbeds as DiscordSendEmbeds) + : undefined; const content = readStringParam(ctx.params, "content", { - required: !asVoice && !componentSpec && !components && !mediaUrl, + required: !asVoice && !componentSpec && !components && !embeds?.length && !mediaUrl, allowEmpty: true, }); const filename = readStringParam(ctx.params, "filename"); const replyTo = readStringParam(ctx.params, "replyTo"); const threadName = readStringParam(ctx.params, "threadName"); - const rawEmbeds = ctx.params.embeds; - const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) - ? (rawEmbeds as DiscordSendEmbeds) - : undefined; const sessionKey = readStringParam(ctx.params, "__sessionKey"); const agentId = readStringParam(ctx.params, "__agentId"); diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 65d623738b7c..45590b9c3909 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -2192,6 +2192,18 @@ describe("handleDiscordMessagingAction", () => { expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]); }); + it("allows embed-only message sends", async () => { + const embeds = [{ title: "Release notes", description: "Version available" }]; + + await handleMessagingAction("sendMessage", { to: "channel:123", embeds }, enableAllActions); + + expect(sendMessageDiscord).toHaveBeenCalledWith( + "channel:123", + "", + expect.objectContaining({ embeds }), + ); + }); + it("ignores empty components objects for regular media sends", async () => { sendMessageDiscord.mockClear(); sendDiscordComponentMessage.mockClear(); diff --git a/extensions/discord/src/internal/payload.ts b/extensions/discord/src/internal/payload.ts index a13753c61533..7ffad48ed705 100644 --- a/extensions/discord/src/internal/payload.ts +++ b/extensions/discord/src/internal/payload.ts @@ -1,5 +1,10 @@ // Discord plugin module implements payload behavior. -import { MessageFlags, type APIEmbed } from "discord-api-types/v10"; +import { + ComponentType, + MessageFlags, + type APIEmbed, + type APIMessageTopLevelComponent, +} from "discord-api-types/v10"; import { Embed } from "./embeds.js"; import { stripUndefinedFields as clean } from "./undefined-fields.js"; @@ -14,7 +19,7 @@ export type MessagePayloadFile = { export type MessagePayloadObject = { content?: string; embeds?: Array; - components?: TopLevelComponents[]; + components?: Array; allowedMentions?: unknown; allowed_mentions?: unknown; flags?: number; @@ -30,17 +35,19 @@ export type TopLevelComponents = { serialize: () => unknown; }; -function serializeAnyComponent(component: { serialize: () => unknown }): unknown { - return component.serialize(); -} - -function payloadHasV2Components(payload: MessagePayloadObject): boolean { - return Boolean(payload.components?.some((component) => component.isV2)); +export function hasDiscordV2Components(components?: MessagePayloadObject["components"]): boolean { + return Boolean( + components?.some( + (component) => + ("isV2" in component && component.isV2) || + ("type" in component && component.type !== ComponentType.ActionRow), + ), + ); } function normalizePayloadFlags(payload: MessagePayloadObject): number | undefined { const flags = payload.ephemeral ? (payload.flags ?? 0) | MessageFlags.Ephemeral : payload.flags; - if (!payloadHasV2Components(payload)) { + if (!hasDiscordV2Components(payload.components)) { return flags; } if (payload.content || payload.embeds?.length) { @@ -57,7 +64,9 @@ export function serializePayload(payload: MessagePayload) { return clean({ content: payload.content, embeds: payload.embeds?.map((entry) => ("serialize" in entry ? entry.serialize() : entry)), - components: payload.components?.map((entry) => serializeAnyComponent(entry)), + components: payload.components?.map((entry) => + "serialize" in entry ? entry.serialize() : entry, + ), allowed_mentions: payload.allowed_mentions ?? payload.allowedMentions, flags, tts: payload.tts, diff --git a/extensions/discord/src/monitor/native-command-reply.test.ts b/extensions/discord/src/monitor/native-command-reply.test.ts index ccd23a9c7d9c..f266497b1fb8 100644 --- a/extensions/discord/src/monitor/native-command-reply.test.ts +++ b/extensions/discord/src/monitor/native-command-reply.test.ts @@ -78,6 +78,83 @@ describe("deliverDiscordInteractionReply", () => { expect(interaction.followUp).not.toHaveBeenCalled(); }); + it.each([true, false])( + "sends embed-only native command replies with preferFollowUp=%s", + async (preferFollowUp) => { + const interaction = createInteraction(); + const embeds = [{ title: "Status", description: "All systems operational" }]; + const payload = { channelData: { discord: { embeds } } }; + + expect(hasRenderableReplyPayload(payload)).toBe(true); + await expect( + deliverDiscordInteractionReply({ + interaction: interaction as never, + payload, + textLimit: 2000, + preferFollowUp, + responseEphemeral: true, + chunkMode: "length", + }), + ).resolves.toBe(true); + + const sender = preferFollowUp ? interaction.followUp : interaction.reply; + expect(sender).toHaveBeenCalledWith({ embeds, ephemeral: true }); + }, + ); + + it.each([false, true])( + "keeps native reply embeds on the first chunk with media=%s", + async (includeMedia) => { + const interaction = createInteraction(); + const embeds = [{ title: "Status" }]; + if (includeMedia) { + loadWebMediaMock.mockResolvedValue({ + buffer: Buffer.from("image"), + fileName: "status.png", + contentType: "image/png", + }); + } + + await deliverDiscordInteractionReply({ + interaction: interaction as never, + payload: { + text: "x".repeat(2_100), + ...(includeMedia ? { mediaUrls: ["file:///tmp/status.png"] } : {}), + channelData: { discord: { embeds } }, + }, + textLimit: 2000, + preferFollowUp: false, + chunkMode: "length", + }); + + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ embeds, ...(includeMedia ? { files: expect.any(Array) } : {}) }), + ); + expect(interaction.followUp).toHaveBeenCalledWith({ content: "x".repeat(100) }); + }, + ); + + it("omits legacy content and embeds from native Components V2 replies", async () => { + const interaction = createInteraction(); + const components = [{ type: 17, components: [{ type: 10, content: "Choose" }] }]; + + await deliverDiscordInteractionReply({ + interaction: interaction as never, + payload: { + text: "legacy fallback", + channelData: { + discord: { components, embeds: [{ title: "legacy embed" }] }, + }, + }, + textLimit: 2000, + preferFollowUp: false, + chunkMode: "length", + }); + + expect(interaction.reply).toHaveBeenCalledWith({ components }); + expect(interaction.followUp).not.toHaveBeenCalled(); + }); + it("sends the detected WebP media type across a real interaction multipart request", async () => { const loopback = await createDiscordLoopbackRest(); loadWebMediaMock.mockResolvedValue({ diff --git a/extensions/discord/src/monitor/native-command-reply.ts b/extensions/discord/src/monitor/native-command-reply.ts index 05d795f5e0d3..aacb4a10cad2 100644 --- a/extensions/discord/src/monitor/native-command-reply.ts +++ b/extensions/discord/src/monitor/native-command-reply.ts @@ -1,3 +1,4 @@ +import type { APIEmbed } from "discord-api-types/v10"; import { createChannelPartialDeliveryError } from "openclaw/plugin-sdk/channel-inbound"; // Discord plugin module implements native command reply behavior. import { PlatformMessageNotDispatchedError } from "openclaw/plugin-sdk/error-runtime"; @@ -9,12 +10,13 @@ import { import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { chunkDiscordTextWithMode } from "../chunk.js"; -import type { - ButtonInteraction, - CommandInteraction, - MessagePayloadFile, - StringSelectMenuInteraction, - TopLevelComponents, +import { + hasDiscordV2Components, + type ButtonInteraction, + type CommandInteraction, + type MessagePayloadFile, + type StringSelectMenuInteraction, + type TopLevelComponents, } from "../internal/discord.js"; export const DISCORD_EMPTY_VISIBLE_REPLY_WARNING = "⚠️ Command produced no visible reply."; @@ -41,17 +43,20 @@ function isDiscordUnknownInteraction(error: unknown): boolean { return false; } -export function hasRenderableReplyPayload(payload: ReplyPayload): boolean { - if (resolveSendableOutboundReplyParts(payload).hasContent) { - return true; - } +function resolveDiscordInteractionMessageParts(payload: ReplyPayload) { const discordData = payload.channelData?.discord as - | { components?: TopLevelComponents[] } + | { components?: TopLevelComponents[]; embeds?: APIEmbed[] } | undefined; - if (Array.isArray(discordData?.components) && discordData.components.length > 0) { - return true; - } - return false; + const { components, embeds } = discordData ?? {}; + return { + components: Array.isArray(components) && components.length > 0 ? components : undefined, + embeds: Array.isArray(embeds) && embeds.length > 0 ? embeds : undefined, + }; +} + +export function hasRenderableReplyPayload(payload: ReplyPayload): boolean { + const { components, embeds } = resolveDiscordInteractionMessageParts(payload); + return resolveSendableOutboundReplyParts(payload).hasContent || Boolean(components || embeds); } export async function safeDiscordInteractionCall( @@ -94,13 +99,8 @@ export async function deliverDiscordInteractionReply(params: { }): Promise { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; const reply = resolveSendableOutboundReplyParts(payload); - const discordData = payload.channelData?.discord as - | { components?: TopLevelComponents[] } - | undefined; - let firstMessageComponents = - Array.isArray(discordData?.components) && discordData.components.length > 0 - ? discordData.components - : undefined; + let { components: firstMessageComponents, embeds: firstMessageEmbeds } = + resolveDiscordInteractionMessageParts(payload); // Interaction acknowledgement/defer state is not delivery for this payload. Only a // successful native send in this invocation can make a later expiry partial. @@ -109,37 +109,27 @@ export async function deliverDiscordInteractionReply(params: { content: string, files?: MessagePayloadFile[], components?: TopLevelComponents[], + embeds?: APIEmbed[], ) => { - const contentPayload = content ? { content } : {}; - const payloadLocal = - files && files.length > 0 - ? { - ...contentPayload, - ...(components ? { components } : {}), - ...(params.responseEphemeral !== undefined - ? { ephemeral: params.responseEphemeral } - : {}), - files, - } - : { - ...contentPayload, - ...(components ? { components } : {}), - ...(params.responseEphemeral !== undefined - ? { ephemeral: params.responseEphemeral } - : {}), - }; + const hasV2 = hasDiscordV2Components(components); + const payloadLocal = { + ...(content && !hasV2 ? { content } : {}), + ...(components ? { components } : {}), + ...(embeds && !hasV2 ? { embeds } : {}), + ...(params.responseEphemeral !== undefined ? { ephemeral: params.responseEphemeral } : {}), + ...(files?.length ? { files } : {}), + }; let result: void | null; try { result = await safeDiscordInteractionCall("interaction send", async () => { if (!preferFollowUp && !payloadDelivered) { await interaction.reply(payloadLocal); - payloadDelivered = true; - firstMessageComponents = undefined; - return; + } else { + await interaction.followUp(payloadLocal); } - await interaction.followUp(payloadLocal); payloadDelivered = true; firstMessageComponents = undefined; + firstMessageEmbeds = undefined; }); } catch (error) { if (!payloadDelivered) { @@ -182,7 +172,7 @@ export async function deliverDiscordInteractionReply(params: { }), ); const caption = chunks[0] ?? ""; - await sendMessage(caption, media, firstMessageComponents); + await sendMessage(caption, media, firstMessageComponents, firstMessageEmbeds); for (const chunk of chunks.slice(1)) { if (!chunk.trim()) { continue; @@ -192,28 +182,25 @@ export async function deliverDiscordInteractionReply(params: { return payloadDelivered; } - if (!reply.hasText && !firstMessageComponents) { + if (!reply.hasText && !firstMessageComponents && !firstMessageEmbeds) { return false; } - let chunks = - reply.text || firstMessageComponents - ? resolveTextChunksWithFallback( - reply.text, - chunkDiscordTextWithMode(reply.text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }), - ) - : []; - if (chunks.length === 0 && firstMessageComponents) { - chunks = [""]; + const chunks = resolveTextChunksWithFallback( + reply.text, + chunkDiscordTextWithMode(reply.text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ); + if (chunks.length === 0) { + chunks.push(""); } for (const chunk of chunks) { - if (!chunk.trim() && !firstMessageComponents) { + if (!chunk.trim() && !firstMessageComponents && !firstMessageEmbeds) { continue; } - await sendMessage(chunk, undefined, firstMessageComponents); + await sendMessage(chunk, undefined, firstMessageComponents, firstMessageEmbeds); } return payloadDelivered; } 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 d63c8766fc94..c237fbe2c6f5 100644 --- a/extensions/discord/src/monitor/native-command.status-direct.test.ts +++ b/extensions/discord/src/monitor/native-command.status-direct.test.ts @@ -214,6 +214,25 @@ describe("discord native /status", () => { expect(interaction.reply).not.toHaveBeenCalled(); }); + it("delivers an embed-only direct status reply without reporting it unavailable", async () => { + const embeds = [{ title: "Status", description: "All systems operational" }]; + runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({ + channelData: { discord: { embeds } }, + }); + const cfg = createConfig(); + const command = await createStatusCommand(cfg); + const interaction = createInteraction(); + + await (command as { run: (interaction: unknown) => Promise }).run(interaction as unknown); + + expect(runtimeModuleMocks.dispatchReplyWithDispatcher).not.toHaveBeenCalled(); + expect(interaction.followUp).toHaveBeenCalledOnce(); + expect(firstMockArg(interaction.followUp, "interaction.followUp")).toStrictEqual({ + embeds, + ephemeral: true, + }); + }); + it("prioritizes direct status replies over matching plugin commands", async () => { const executePluginCommand = vi.fn(async () => ({ text: "plugin status" })); const cfg = createConfig(); diff --git a/extensions/discord/src/send.message-request.test.ts b/extensions/discord/src/send.message-request.test.ts index 4eaed6d70f15..9fc1f1906093 100644 --- a/extensions/discord/src/send.message-request.test.ts +++ b/extensions/discord/src/send.message-request.test.ts @@ -1,4 +1,6 @@ +import { MessageFlags, type APIMessageTopLevelComponent } from "discord-api-types/v10"; import { describe, expect, it } from "vitest"; +import { Container, serializePayload, TextDisplay } from "./internal/discord.js"; import { buildDiscordMessageRequest } from "./send.message-request.js"; describe("buildDiscordMessageRequest", () => { @@ -31,4 +33,67 @@ describe("buildDiscordMessageRequest", () => { expect(body).toEqual({ content: "hello" }); }); + + it("preserves already-serialized legacy Discord components", () => { + const components: APIMessageTopLevelComponent[] = [ + { + type: 1, + components: [{ type: 2, style: 1, custom_id: "open", label: "Open" }], + }, + ]; + + expect( + buildDiscordMessageRequest({ + endpoint: "create-message", + text: "Choose an action", + components, + }), + ).toMatchObject({ content: "Choose an action", components, enforce_nonce: true }); + }); + + it("marks already-serialized Components V2 messages and omits legacy content", () => { + const components: APIMessageTopLevelComponent[] = [ + { type: 17, components: [{ type: 10, content: "Choose an action" }] }, + ]; + + const body = buildDiscordMessageRequest({ + endpoint: "create-message", + text: "legacy fallback", + components, + }); + + expect(body).toMatchObject({ + components, + flags: MessageFlags.IsComponentsV2, + enforce_nonce: true, + }); + expect(body).not.toHaveProperty("content"); + }); + + it("preserves Components V2 behavior for component builder instances", () => { + const body = buildDiscordMessageRequest({ + endpoint: "create-message", + text: "legacy fallback", + components: [new Container([new TextDisplay("Choose an action")])], + }); + + expect(body).toMatchObject({ + components: [{ type: 17, components: [{ type: 10, content: "Choose an action" }] }], + flags: MessageFlags.IsComponentsV2, + }); + expect(body).not.toHaveProperty("content"); + }); + + it.each([ + { name: "content", content: "forbidden" }, + { name: "embeds", embeds: [{ title: "forbidden" }] }, + ])("rejects legacy $name alongside raw Components V2", ({ content, embeds }) => { + const components: APIMessageTopLevelComponent[] = [ + { type: 17, components: [{ type: 10, content: "Choose an action" }] }, + ]; + + expect(() => serializePayload({ content, embeds, components })).toThrow( + "Discord Components V2 payloads cannot include content or embeds", + ); + }); }); diff --git a/extensions/discord/src/send.message-request.ts b/extensions/discord/src/send.message-request.ts index 513db7ed69a0..412ccf4f80e5 100644 --- a/extensions/discord/src/send.message-request.ts +++ b/extensions/discord/src/send.message-request.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { MessageFlags, type APIAllowedMentions, type APIEmbed } from "discord-api-types/v10"; import { Embed, + hasDiscordV2Components, serializePayload, type MessagePayloadFile, type MessagePayloadObject, @@ -14,6 +15,7 @@ export { stripUndefinedFields } from "./internal/undefined-fields.js"; const SUPPRESS_EMBEDS_FLAG = MessageFlags.SuppressEmbeds; export const SUPPRESS_NOTIFICATIONS_FLAG = MessageFlags.SuppressNotifications; +type DiscordMessageComponents = NonNullable; type DiscordSendComponentFactory = (text: string) => TopLevelComponents[]; export type DiscordSendComponents = TopLevelComponents[] | DiscordSendComponentFactory; export type DiscordSendEmbeds = Array; @@ -24,10 +26,10 @@ export function createDiscordMessageNonce(): string { } export function resolveDiscordSendComponents(params: { - components?: DiscordSendComponents; + components?: DiscordSendComponents | DiscordMessageComponents; text: string; isFirst: boolean; -}): TopLevelComponents[] | undefined { +}): DiscordMessageComponents | undefined { if (!params.components || !params.isFirst) { return undefined; } @@ -55,14 +57,14 @@ export function resolveDiscordSendEmbeds(params: { function buildDiscordMessagePayload(params: { text: string; - components?: TopLevelComponents[]; + components?: DiscordMessageComponents; embeds?: Embed[]; allowedMentions?: DiscordAllowedMentions; flags?: number; files?: MessagePayloadFile[]; }): MessagePayloadObject { const payload: MessagePayloadObject = {}; - const hasV2 = hasV2Components(params.components); + const hasV2 = hasDiscordV2Components(params.components); const trimmed = params.text.trim(); if (!hasV2 && trimmed) { payload.content = params.text; @@ -108,7 +110,7 @@ export function resolveDiscordSuppressEmbeds(params: { type DiscordMessageRequestParams = { text: string; - components?: TopLevelComponents[]; + components?: DiscordMessageComponents; embeds?: Embed[]; allowedMentions?: DiscordAllowedMentions; files?: MessagePayloadFile[]; @@ -131,7 +133,3 @@ export function buildDiscordMessageRequest(params: DiscordMessageRequestParams) ...(nonce ? { enforce_nonce: true } : {}), }; } - -function hasV2Components(components?: TopLevelComponents[]): boolean { - return Boolean(components?.some((component) => "isV2" in component && component.isV2)); -} diff --git a/extensions/discord/src/send.outbound.ts b/extensions/discord/src/send.outbound.ts index e54f56cee7d4..f9a4e42471e5 100644 --- a/extensions/discord/src/send.outbound.ts +++ b/extensions/discord/src/send.outbound.ts @@ -42,7 +42,6 @@ import { sendDiscordText, type DiscordAllowedMentions, type DiscordSendProgress, - type DiscordSendComponents, type DiscordSendEmbeds, } from "./send.shared.js"; import type { DiscordSendResult } from "./send.types.js"; @@ -63,7 +62,7 @@ type DiscordSendOpts = { maxLinesPerMessage?: number; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; - components?: DiscordSendComponents; + components?: Parameters[0]["components"]; embeds?: DiscordSendEmbeds; silent?: boolean; threadId?: string | number; diff --git a/extensions/discord/src/send.sends-basic-channel-messages.test.ts b/extensions/discord/src/send.sends-basic-channel-messages.test.ts index 78af5bc0cee8..29a1d77e7ae8 100644 --- a/extensions/discord/src/send.sends-basic-channel-messages.test.ts +++ b/extensions/discord/src/send.sends-basic-channel-messages.test.ts @@ -1,4 +1,10 @@ -import { ChannelType, MessageFlags, PermissionFlagsBits, Routes } from "discord-api-types/v10"; +import { + ChannelType, + MessageFlags, + PermissionFlagsBits, + Routes, + type APIMessageTopLevelComponent, +} from "discord-api-types/v10"; // Discord tests cover send.sends basic channel messages plugin behavior. import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -285,6 +291,183 @@ describe("sendMessageDiscord", () => { expect(requireRestBody(postMock).flags).toBe(MessageFlags.SuppressEmbeds); }); + it("sends embed-only messages with a card receipt and enforced nonce", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "embed1", channel_id: "789" }); + const onDeliveryResult = vi.fn(); + + const result = await sendMessageDiscord("channel:789", "", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + embeds: [{ title: "Release notes", description: "Version available" }], + reply: { messageId: "orig-123", scope: "first" }, + allowedMentions: { parse: [] }, + onDeliveryResult, + }); + + expectSingleReceiptPart(result.receipt, { platformMessageId: "embed1", kind: "card" }); + expectSingleReceiptPart(onDeliveryResult.mock.calls[0]?.[0]?.receipt, { + platformMessageId: "embed1", + kind: "card", + }); + expect(requireRestBody(postMock)).toMatchObject({ + embeds: [{ title: "Release notes", description: "Version available" }], + allowed_mentions: { parse: [] }, + message_reference: { message_id: "orig-123", fail_if_not_exists: false }, + enforce_nonce: true, + }); + expect(requireRestBody(postMock)).not.toHaveProperty("content"); + expect(requireRestBody(postMock)).not.toHaveProperty("flags"); + }); + + it.each([ + { name: "without message text", text: "" }, + { name: "alongside message text", text: "Choose an action" }, + ])("sends raw native Discord action rows $name", async ({ text }) => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "component1", channel_id: "789" }); + const components: APIMessageTopLevelComponent[] = [ + { + type: 1, + components: [{ type: 2, style: 1, custom_id: "open", label: "Open" }], + }, + ]; + + const result = await sendMessageDiscord("channel:789", text, { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + components, + }); + + expectSingleReceiptPart(result.receipt, { platformMessageId: "component1", kind: "card" }); + expect(requireRestBody(postMock)).toMatchObject({ components, enforce_nonce: true }); + if (text) { + expect(requireRestBody(postMock).content).toBe(text); + } else { + expect(requireRestBody(postMock)).not.toHaveProperty("content"); + } + }); + + it("sends raw Components V2 without legacy content or embeds", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock.mockResolvedValue({ id: "component2", channel_id: "789" }); + const components: APIMessageTopLevelComponent[] = [ + { type: 17, components: [{ type: 10, content: "Choose an action" }] }, + ]; + + const result = await sendMessageDiscord("channel:789", "legacy fallback", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + components, + embeds: [{ title: "legacy embed" }], + }); + + expectSingleReceiptPart(result.receipt, { platformMessageId: "component2", kind: "card" }); + expect(requireRestBody(postMock)).toMatchObject({ + components, + flags: MessageFlags.IsComponentsV2, + enforce_nonce: true, + }); + expect(requireRestBody(postMock)).not.toHaveProperty("content"); + expect(requireRestBody(postMock)).not.toHaveProperty("embeds"); + }); + + it("keeps native components and embeds on the first message chunk only", async () => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + postMock + .mockResolvedValueOnce({ id: "component1", channel_id: "789" }) + .mockResolvedValueOnce({ id: "component2", channel_id: "789" }); + const components: APIMessageTopLevelComponent[] = [ + { + type: 1, + components: [{ type: 2, style: 1, custom_id: "open", label: "Open" }], + }, + ]; + const onDeliveryResult = vi.fn(); + + await sendMessageDiscord("channel:789", "a".repeat(2_500), { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + components, + embeds: [{ title: "Release notes" }], + reply: { messageId: "orig-123", scope: "first" }, + onDeliveryResult, + }); + + expect(postMock).toHaveBeenCalledTimes(2); + expect(requireRestBody(postMock, 0)).toMatchObject({ + components, + embeds: [{ title: "Release notes" }], + message_reference: { message_id: "orig-123", fail_if_not_exists: false }, + }); + expect(requireRestBody(postMock, 1)).not.toHaveProperty("components"); + expect(requireRestBody(postMock, 1)).not.toHaveProperty("embeds"); + expect(requireRestBody(postMock, 1)).not.toHaveProperty("message_reference"); + expect(onDeliveryResult.mock.calls.map((call) => call[0]?.receipt.parts[0]?.kind)).toEqual([ + "card", + "text", + ]); + }); + + it("delivers embed-only and native Components V2 messages over real HTTP", async () => { + const loopback = await createDiscordLoopbackRest(); + try { + await sendMessageDiscord("channel:789", "", { + rest: loopback.rest, + token: "test-token", + cfg: DISCORD_TEST_CFG, + embeds: [{ title: "Release notes" }], + }); + await sendMessageDiscord("channel:789", "", { + rest: loopback.rest, + token: "test-token", + cfg: DISCORD_TEST_CFG, + components: [{ type: 17, components: [{ type: 10, content: "Choose" }] }], + }); + + const messageRequests = loopback.requests.filter((request) => request.method === "POST"); + expect(messageRequests).toHaveLength(2); + expect(JSON.parse(messageRequests[0]?.body ?? "{}")).toMatchObject({ + embeds: [{ title: "Release notes" }], + enforce_nonce: true, + }); + expect(JSON.parse(messageRequests[1]?.body ?? "{}")).toMatchObject({ + components: [{ type: 17, components: [{ type: 10, content: "Choose" }] }], + flags: MessageFlags.IsComponentsV2 | MessageFlags.SuppressEmbeds, + enforce_nonce: true, + }); + } finally { + await loopback.close(); + } + }); + + it.each([ + { name: "no components", components: undefined }, + { name: "empty component array", components: [] }, + { name: "empty component factory", components: () => [] }, + ])("still rejects empty messages with $name", async ({ components }) => { + const { rest, postMock, getMock } = makeDiscordRest(); + getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); + + await expect( + sendMessageDiscord("channel:789", "", { + rest, + token: "t", + cfg: DISCORD_TEST_CFG, + components, + }), + ).rejects.toThrow("Message must be non-empty for Discord sends"); + expect(postMock).not.toHaveBeenCalled(); + }); + it.each(DISCORD_MARKDOWN_GOLDENS)("$name", async ({ before, after }) => { const { rest, postMock, getMock } = makeDiscordRest(); getMock.mockResolvedValueOnce({ type: ChannelType.GuildText }); diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index 8da17be814fa..ef86ca1165c2 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -34,7 +34,6 @@ import { resolveDiscordSendComponents, resolveDiscordSendEmbeds, type DiscordAllowedMentions, - type DiscordSendComponents, type DiscordSendEmbeds, } from "./send.message-request.js"; import { fetchChannelPermissionsDiscord, isThreadChannelType } from "./send.permissions.js"; @@ -306,7 +305,7 @@ export function buildDiscordTextChunks( export type DiscordSendProgress = ( result: { id: string; channel_id: string }, - kind: "text" | "media", + kind: "text" | "media" | "card", replyToId?: string, ) => Promise | void; @@ -317,7 +316,7 @@ type DiscordTextSendParams = { request: DiscordRequest; reply?: DiscordReplyReference; maxLinesPerMessage?: number; - components?: DiscordSendComponents; + components?: Parameters[0]["components"]; embeds?: DiscordSendEmbeds; allowedMentions?: DiscordAllowedMentions; chunkMode?: ChunkMode; @@ -346,18 +345,22 @@ async function sendDiscordText(params: DiscordTextSendParams) { onResult, onPlatformSendDispatch, } = params; - if (!text.trim()) { - throw new Error("Message must be non-empty for Discord sends"); - } const chunks = buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode, maxChars }); + if (!chunks.length) { + chunks.push(""); + } const sendChunk = async (chunk: string, isFirst: boolean) => { - const chunkReplyTo = resolveDiscordReplyMessageId(reply, isFirst); const chunkComponents = resolveDiscordSendComponents({ components, text: chunk, isFirst, }); const chunkEmbeds = resolveDiscordSendEmbeds({ embeds, isFirst }); + if (!chunk.trim() && !chunkComponents?.length && !chunkEmbeds?.length) { + throw new Error("Message must be non-empty for Discord sends"); + } + const chunkReplyTo = resolveDiscordReplyMessageId(reply, isFirst); + const kind: "card" | "text" = chunkComponents?.length || chunkEmbeds?.length ? "card" : "text"; const flags = resolveDiscordMessageFlags({ silent, suppressEmbeds: suppressEmbeds && !chunkEmbeds?.length, @@ -377,12 +380,12 @@ async function sendDiscordText(params: DiscordTextSendParams) { "text", { safety: "nonce-protected-create" }, )) as { id: string; channel_id: string }; - return { result, replyToId: chunkReplyTo }; + return { result, replyToId: chunkReplyTo, kind }; }; if (chunks.length === 1) { const chunk = expectDefined(chunks.at(0), "single Discord text chunk"); - const { result, replyToId } = await sendChunk(chunk, true); - await onResult?.(result, "text", replyToId); + const { result, replyToId, kind } = await sendChunk(chunk, true); + await onResult?.(result, kind, replyToId); return { ...result, platformMessageIds: result.id ? [result.id] : [] }; } const platformMessageIds: string[] = []; @@ -390,7 +393,7 @@ async function sendDiscordText(params: DiscordTextSendParams) { for (const [index, chunk] of chunks.entries()) { const sent = await sendChunk(chunk, index === 0); last = sent.result; - await onResult?.(last, "text", sent.replyToId); + await onResult?.(last, sent.kind, sent.replyToId); if (last.id) { platformMessageIds.push(last.id); }