mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-24 16:21:04 +00:00
408 lines
14 KiB
TypeScript
408 lines
14 KiB
TypeScript
import { ButtonStyle, TextInputStyle } from "discord-api-types/v10";
|
|
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
import type {
|
|
DiscordComponentBlock,
|
|
DiscordComponentButtonSpec,
|
|
DiscordComponentButtonStyle,
|
|
DiscordComponentMessageSpec,
|
|
DiscordComponentModalFieldType,
|
|
DiscordComponentSectionAccessory,
|
|
DiscordComponentSelectOption,
|
|
DiscordComponentSelectSpec,
|
|
DiscordComponentSelectType,
|
|
DiscordModalFieldSpec,
|
|
DiscordModalSpec,
|
|
} from "./components.types.js";
|
|
|
|
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
|
|
|
|
type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2;
|
|
|
|
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
|
|
["row", "actions"],
|
|
["action-row", "actions"],
|
|
]);
|
|
|
|
function requireObject(value: unknown, label: string): Record<string, unknown> {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
throw new Error(`${label} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string {
|
|
if (typeof value !== "string") {
|
|
throw new Error(`${label} must be a string`);
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!opts?.allowEmpty && !trimmed) {
|
|
throw new Error(`${label} cannot be empty`);
|
|
}
|
|
return opts?.allowEmpty ? value : trimmed;
|
|
}
|
|
|
|
function readOptionalString(value: unknown): string | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function readOptionalStringArray(value: unknown, label: string): string[] | undefined {
|
|
if (value === undefined) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(value)) {
|
|
throw new Error(`${label} must be an array`);
|
|
}
|
|
if (value.length === 0) {
|
|
return undefined;
|
|
}
|
|
return value.map((entry, index) => readString(entry, `${label}[${index}]`));
|
|
}
|
|
|
|
function readOptionalNumber(value: unknown): number | undefined {
|
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
return undefined;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readOptionalEmoji(value: unknown, label: string) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const obj = value as { name?: unknown; id?: unknown; animated?: unknown };
|
|
return {
|
|
name: readString(obj.name, `${label}.name`),
|
|
id: readOptionalString(obj.id),
|
|
animated: typeof obj.animated === "boolean" ? obj.animated : undefined,
|
|
};
|
|
}
|
|
|
|
export function normalizeModalFieldName(value: string | undefined, index: number) {
|
|
const trimmed = value?.trim();
|
|
if (trimmed) {
|
|
return trimmed;
|
|
}
|
|
return `field_${index + 1}`;
|
|
}
|
|
|
|
function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
|
|
throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`);
|
|
}
|
|
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
|
|
if (!attachmentName) {
|
|
throw new Error(`${label} must include an attachment filename`);
|
|
}
|
|
return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`;
|
|
}
|
|
|
|
export function resolveDiscordComponentAttachmentName(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) {
|
|
throw new Error(
|
|
`Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`,
|
|
);
|
|
}
|
|
const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim();
|
|
if (!attachmentName) {
|
|
throw new Error("Attachment reference must include a filename");
|
|
}
|
|
return attachmentName;
|
|
}
|
|
|
|
export function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle {
|
|
switch (normalizeLowercaseStringOrEmpty(style ?? "primary")) {
|
|
case "secondary":
|
|
return ButtonStyle.Secondary;
|
|
case "success":
|
|
return ButtonStyle.Success;
|
|
case "danger":
|
|
return ButtonStyle.Danger;
|
|
case "link":
|
|
return ButtonStyle.Link;
|
|
case "primary":
|
|
default:
|
|
return ButtonStyle.Primary;
|
|
}
|
|
}
|
|
|
|
export function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) {
|
|
return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short;
|
|
}
|
|
|
|
function normalizeBlockType(raw: string) {
|
|
const lowered = normalizeLowercaseStringOrEmpty(raw);
|
|
return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]);
|
|
}
|
|
|
|
function parseSelectOptions(
|
|
raw: unknown,
|
|
label: string,
|
|
): DiscordComponentSelectOption[] | undefined {
|
|
if (raw === undefined) {
|
|
return undefined;
|
|
}
|
|
if (!Array.isArray(raw)) {
|
|
throw new Error(`${label} must be an array`);
|
|
}
|
|
return raw.map((entry, index) => {
|
|
const obj = requireObject(entry, `${label}[${index}]`);
|
|
return {
|
|
label: readString(obj.label, `${label}[${index}].label`),
|
|
value: readString(obj.value, `${label}[${index}].value`),
|
|
description: readOptionalString(obj.description),
|
|
emoji: readOptionalEmoji(obj.emoji, `${label}[${index}].emoji`),
|
|
default: typeof obj.default === "boolean" ? obj.default : undefined,
|
|
};
|
|
});
|
|
}
|
|
|
|
function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec {
|
|
const obj = requireObject(raw, label);
|
|
const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
|
|
const url = readOptionalString(obj.url);
|
|
if ((style === "link" || url) && !url) {
|
|
throw new Error(`${label}.url is required for link buttons`);
|
|
}
|
|
return {
|
|
label: readString(obj.label, `${label}.label`),
|
|
style,
|
|
url,
|
|
callbackData: readOptionalString(obj.callbackData),
|
|
emoji: readOptionalEmoji(obj.emoji, `${label}.emoji`),
|
|
disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined,
|
|
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
|
};
|
|
}
|
|
|
|
function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec {
|
|
const obj = requireObject(raw, label);
|
|
const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined;
|
|
const allowedTypes: DiscordComponentSelectType[] = [
|
|
"string",
|
|
"user",
|
|
"role",
|
|
"mentionable",
|
|
"channel",
|
|
];
|
|
if (type && !allowedTypes.includes(type)) {
|
|
throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`);
|
|
}
|
|
return {
|
|
type,
|
|
callbackData: readOptionalString(obj.callbackData),
|
|
placeholder: readOptionalString(obj.placeholder),
|
|
minValues: readOptionalNumber(obj.minValues),
|
|
maxValues: readOptionalNumber(obj.maxValues),
|
|
options: parseSelectOptions(obj.options, `${label}.options`),
|
|
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
|
};
|
|
}
|
|
|
|
function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec {
|
|
const obj = requireObject(raw, label);
|
|
const type = normalizeLowercaseStringOrEmpty(
|
|
readString(obj.type, `${label}.type`),
|
|
) as DiscordComponentModalFieldType;
|
|
const supported: DiscordComponentModalFieldType[] = [
|
|
"text",
|
|
"checkbox",
|
|
"radio",
|
|
"select",
|
|
"role-select",
|
|
"user-select",
|
|
];
|
|
if (!supported.includes(type)) {
|
|
throw new Error(`${label}.type must be one of ${supported.join(", ")}`);
|
|
}
|
|
const options = parseSelectOptions(obj.options, `${label}.options`);
|
|
if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) {
|
|
throw new Error(`${label}.options is required for ${type} fields`);
|
|
}
|
|
return {
|
|
type,
|
|
name: normalizeModalFieldName(readOptionalString(obj.name), index),
|
|
label: readString(obj.label, `${label}.label`),
|
|
description: readOptionalString(obj.description),
|
|
placeholder: readOptionalString(obj.placeholder),
|
|
required: typeof obj.required === "boolean" ? obj.required : undefined,
|
|
options,
|
|
minValues: readOptionalNumber(obj.minValues),
|
|
maxValues: readOptionalNumber(obj.maxValues),
|
|
minLength: readOptionalNumber(obj.minLength),
|
|
maxLength: readOptionalNumber(obj.maxLength),
|
|
style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"],
|
|
};
|
|
}
|
|
|
|
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
|
|
const obj = requireObject(raw, label);
|
|
const typeRaw = normalizeLowercaseStringOrEmpty(readString(obj.type, `${label}.type`));
|
|
const type = normalizeBlockType(typeRaw);
|
|
switch (type) {
|
|
case "text":
|
|
return {
|
|
type: "text",
|
|
text: readString(obj.text, `${label}.text`),
|
|
};
|
|
case "section": {
|
|
const text = readOptionalString(obj.text);
|
|
const textsRaw = obj.texts;
|
|
const texts = Array.isArray(textsRaw)
|
|
? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`))
|
|
: undefined;
|
|
if (!text && (!texts || texts.length === 0)) {
|
|
throw new Error(`${label}.text or ${label}.texts is required for section blocks`);
|
|
}
|
|
let accessory: DiscordComponentSectionAccessory | undefined;
|
|
if (obj.accessory !== undefined) {
|
|
const accessoryObj = requireObject(obj.accessory, `${label}.accessory`);
|
|
const accessoryType = normalizeLowercaseStringOrEmpty(
|
|
readString(accessoryObj.type, `${label}.accessory.type`),
|
|
);
|
|
if (accessoryType === "thumbnail") {
|
|
accessory = {
|
|
type: "thumbnail",
|
|
url: readString(accessoryObj.url, `${label}.accessory.url`),
|
|
};
|
|
} else if (accessoryType === "button") {
|
|
accessory = {
|
|
type: "button",
|
|
button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`),
|
|
};
|
|
} else {
|
|
throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`);
|
|
}
|
|
}
|
|
return {
|
|
type: "section",
|
|
text,
|
|
texts,
|
|
accessory,
|
|
};
|
|
}
|
|
case "separator": {
|
|
const spacingRaw = obj.spacing;
|
|
let spacing: DiscordComponentSeparatorSpacing | undefined;
|
|
if (spacingRaw === "small" || spacingRaw === "large") {
|
|
spacing = spacingRaw;
|
|
} else if (spacingRaw === 1 || spacingRaw === 2) {
|
|
spacing = spacingRaw;
|
|
} else if (spacingRaw !== undefined) {
|
|
throw new Error(`${label}.spacing must be "small", "large", 1, or 2`);
|
|
}
|
|
const divider = typeof obj.divider === "boolean" ? obj.divider : undefined;
|
|
return {
|
|
type: "separator",
|
|
spacing,
|
|
divider,
|
|
};
|
|
}
|
|
case "actions": {
|
|
const buttonsRaw = obj.buttons;
|
|
const buttons = Array.isArray(buttonsRaw)
|
|
? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`))
|
|
: undefined;
|
|
const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined;
|
|
if ((!buttons || buttons.length === 0) && !select) {
|
|
throw new Error(`${label} requires buttons or select`);
|
|
}
|
|
if (buttons && select) {
|
|
throw new Error(`${label} cannot include both buttons and select`);
|
|
}
|
|
return {
|
|
type: "actions",
|
|
buttons,
|
|
select,
|
|
};
|
|
}
|
|
case "media-gallery": {
|
|
const itemsRaw = obj.items;
|
|
if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) {
|
|
throw new Error(`${label}.items must be a non-empty array`);
|
|
}
|
|
const items = itemsRaw.map((entry, idx) => {
|
|
const itemObj = requireObject(entry, `${label}.items[${idx}]`);
|
|
return {
|
|
url: readString(itemObj.url, `${label}.items[${idx}].url`),
|
|
description: readOptionalString(itemObj.description),
|
|
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
|
|
};
|
|
});
|
|
return {
|
|
type: "media-gallery",
|
|
items,
|
|
};
|
|
}
|
|
case "file": {
|
|
const file = readString(obj.file, `${label}.file`);
|
|
return {
|
|
type: "file",
|
|
file: normalizeAttachmentRef(file, `${label}.file`),
|
|
spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined,
|
|
};
|
|
}
|
|
default:
|
|
throw new Error(`${label}.type must be a supported component block`);
|
|
}
|
|
}
|
|
|
|
export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null {
|
|
if (raw === undefined || raw === null) {
|
|
return null;
|
|
}
|
|
const obj = requireObject(raw, "components");
|
|
const blocksRaw = obj.blocks;
|
|
const blocks = Array.isArray(blocksRaw)
|
|
? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`))
|
|
: undefined;
|
|
const modalRaw = obj.modal;
|
|
const reusable = typeof obj.reusable === "boolean" ? obj.reusable : undefined;
|
|
let modal: DiscordModalSpec | undefined;
|
|
if (modalRaw !== undefined) {
|
|
const modalObj = requireObject(modalRaw, "components.modal");
|
|
const fieldsRaw = modalObj.fields;
|
|
if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) {
|
|
throw new Error("components.modal.fields must be a non-empty array");
|
|
}
|
|
if (fieldsRaw.length > 5) {
|
|
throw new Error("components.modal.fields supports up to 5 inputs");
|
|
}
|
|
const fields = fieldsRaw.map((entry, idx) =>
|
|
parseModalField(entry, `components.modal.fields[${idx}]`, idx),
|
|
);
|
|
modal = {
|
|
title: readString(modalObj.title, "components.modal.title"),
|
|
callbackData: readOptionalString(modalObj.callbackData),
|
|
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
|
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
|
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
|
|
fields,
|
|
};
|
|
}
|
|
return {
|
|
text: readOptionalString(obj.text),
|
|
reusable,
|
|
container:
|
|
typeof obj.container === "object" && obj.container && !Array.isArray(obj.container)
|
|
? {
|
|
accentColor: (obj.container as { accentColor?: unknown }).accentColor as
|
|
| string
|
|
| number
|
|
| undefined,
|
|
spoiler:
|
|
typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean"
|
|
? ((obj.container as { spoiler?: boolean }).spoiler as boolean)
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
blocks,
|
|
modal,
|
|
};
|
|
}
|