Files
Moltbot/src/discord/send.components.ts
2026-02-17 13:36:48 +09:00

170 lines
5.4 KiB
TypeScript

import {
serializePayload,
type MessagePayloadFile,
type MessagePayloadObject,
type RequestClient,
} from "@buape/carbon";
import type { APIChannel } from "discord-api-types/v10";
import { ChannelType, Routes } from "discord-api-types/v10";
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";
import type { DiscordSendResult } from "./send.types.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,
};
}