fix(discord): deliver embed-only and component-only messages (#128152)

This commit is contained in:
Peter Steinberger
2026-08-23 05:01:17 -07:00
committed by GitHub
parent 3b14c819e6
commit 1475b67111
14 changed files with 477 additions and 103 deletions
+1 -1
View File
@@ -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
@@ -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();
@@ -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");
@@ -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");
@@ -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();
+19 -10
View File
@@ -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<APIEmbed | Embed>;
components?: TopLevelComponents[];
components?: Array<TopLevelComponents | APIMessageTopLevelComponent>;
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,
@@ -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({
@@ -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<T>(
@@ -94,13 +99,8 @@ export async function deliverDiscordInteractionReply(params: {
}): Promise<boolean> {
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;
}
@@ -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<void> }).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();
@@ -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",
);
});
});
@@ -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<MessagePayloadObject["components"]>;
type DiscordSendComponentFactory = (text: string) => TopLevelComponents[];
export type DiscordSendComponents = TopLevelComponents[] | DiscordSendComponentFactory;
export type DiscordSendEmbeds = Array<APIEmbed | Embed>;
@@ -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));
}
+1 -2
View File
@@ -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<typeof resolveDiscordSendComponents>[0]["components"];
embeds?: DiscordSendEmbeds;
silent?: boolean;
threadId?: string | number;
@@ -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 });
+14 -11
View File
@@ -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> | void;
@@ -317,7 +316,7 @@ type DiscordTextSendParams = {
request: DiscordRequest;
reply?: DiscordReplyReference;
maxLinesPerMessage?: number;
components?: DiscordSendComponents;
components?: Parameters<typeof resolveDiscordSendComponents>[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);
}