mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(discord): deliver embed-only and component-only messages (#128152)
This commit is contained in:
committed by
GitHub
parent
3b14c819e6
commit
1475b67111
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user