mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 12:21:24 +00:00
Discord: add component v2 UI tool support (#17419)
This commit is contained in:
169
src/discord/send.components.ts
Normal file
169
src/discord/send.components.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { APIChannel } from "discord-api-types/v10";
|
||||
import {
|
||||
serializePayload,
|
||||
type MessagePayloadFile,
|
||||
type MessagePayloadObject,
|
||||
type RequestClient,
|
||||
} from "@buape/carbon";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { loadWebMedia } from "../web/media.js";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
import {
|
||||
buildDiscordComponentMessage,
|
||||
buildDiscordComponentMessageFlags,
|
||||
resolveDiscordComponentAttachmentName,
|
||||
type DiscordComponentMessageSpec,
|
||||
} from "./components.js";
|
||||
import {
|
||||
buildDiscordSendError,
|
||||
createDiscordClient,
|
||||
parseAndResolveRecipient,
|
||||
resolveChannelId,
|
||||
stripUndefinedFields,
|
||||
SUPPRESS_NOTIFICATIONS_FLAG,
|
||||
} from "./send.shared.js";
|
||||
|
||||
const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);
|
||||
|
||||
function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
|
||||
const names: string[] = [];
|
||||
for (const block of spec.blocks ?? []) {
|
||||
if (block.type === "file") {
|
||||
names.push(resolveDiscordComponentAttachmentName(block.file));
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
type DiscordComponentSendOpts = {
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
rest?: RequestClient;
|
||||
silent?: boolean;
|
||||
replyTo?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
export async function sendDiscordComponentMessage(
|
||||
to: string,
|
||||
spec: DiscordComponentMessageSpec,
|
||||
opts: DiscordComponentSendOpts = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
|
||||
const { token, rest, request } = createDiscordClient(opts, cfg);
|
||||
const recipient = await parseAndResolveRecipient(to, opts.accountId);
|
||||
const { channelId } = await resolveChannelId(rest, recipient, request);
|
||||
|
||||
let channelType: number | undefined;
|
||||
try {
|
||||
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
|
||||
channelType = channel?.type;
|
||||
} catch {
|
||||
channelType = undefined;
|
||||
}
|
||||
|
||||
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
|
||||
throw new Error("Discord components are not supported in forum-style channels");
|
||||
}
|
||||
|
||||
const buildResult = buildDiscordComponentMessage({
|
||||
spec,
|
||||
sessionKey: opts.sessionKey,
|
||||
agentId: opts.agentId,
|
||||
accountId: accountInfo.accountId,
|
||||
});
|
||||
const flags = buildDiscordComponentMessageFlags(buildResult.components);
|
||||
const finalFlags = opts.silent
|
||||
? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
|
||||
: (flags ?? undefined);
|
||||
const messageReference = opts.replyTo
|
||||
? { message_id: opts.replyTo, fail_if_not_exists: false }
|
||||
: undefined;
|
||||
|
||||
const attachmentNames = extractComponentAttachmentNames(spec);
|
||||
const uniqueAttachmentNames = [...new Set(attachmentNames)];
|
||||
if (uniqueAttachmentNames.length > 1) {
|
||||
throw new Error(
|
||||
"Discord component attachments currently support a single file. Use media-gallery for multiple files.",
|
||||
);
|
||||
}
|
||||
const expectedAttachmentName = uniqueAttachmentNames[0];
|
||||
let files: MessagePayloadFile[] | undefined;
|
||||
if (opts.mediaUrl) {
|
||||
const media = await loadWebMedia(opts.mediaUrl, { localRoots: opts.mediaLocalRoots });
|
||||
const filenameOverride = opts.filename?.trim();
|
||||
const fileName = filenameOverride || media.fileName || "upload";
|
||||
if (expectedAttachmentName && expectedAttachmentName !== fileName) {
|
||||
throw new Error(
|
||||
`Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${fileName}". Update components.blocks[].file or provide a matching filename.`,
|
||||
);
|
||||
}
|
||||
let fileData: Blob;
|
||||
if (media.buffer instanceof Blob) {
|
||||
fileData = media.buffer;
|
||||
} else {
|
||||
const arrayBuffer = new ArrayBuffer(media.buffer.byteLength);
|
||||
new Uint8Array(arrayBuffer).set(media.buffer);
|
||||
fileData = new Blob([arrayBuffer]);
|
||||
}
|
||||
files = [{ data: fileData, name: fileName }];
|
||||
} else if (expectedAttachmentName) {
|
||||
throw new Error(
|
||||
"Discord component file blocks require a media attachment (media/path/filePath).",
|
||||
);
|
||||
}
|
||||
|
||||
const payload: MessagePayloadObject = {
|
||||
components: buildResult.components,
|
||||
...(finalFlags ? { flags: finalFlags } : {}),
|
||||
...(files ? { files } : {}),
|
||||
};
|
||||
const body = stripUndefinedFields({
|
||||
...serializePayload(payload),
|
||||
...(messageReference ? { message_reference: messageReference } : {}),
|
||||
});
|
||||
|
||||
let result: { id: string; channel_id: string };
|
||||
try {
|
||||
result = (await request(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(channelId), {
|
||||
body,
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"components",
|
||||
)) as { id: string; channel_id: string };
|
||||
} catch (err) {
|
||||
throw await buildDiscordSendError(err, {
|
||||
channelId,
|
||||
rest,
|
||||
token,
|
||||
hasMedia: Boolean(files?.length),
|
||||
});
|
||||
}
|
||||
|
||||
registerDiscordComponentEntries({
|
||||
entries: buildResult.entries,
|
||||
modals: buildResult.modals,
|
||||
messageId: result.id,
|
||||
});
|
||||
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: result.id ?? "unknown",
|
||||
channelId: result.channel_id ?? channelId,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user