Files
openclaw/extensions/discord/src/components.parse.ts
Peter Steinberger fa03d9b913 refactor: consolidate coercion helpers (#121366)
* refactor: consolidate coercion helpers

* fix: remove duplicate coercion imports

* fix: preserve serialized coercion guard

* chore: ratchet coercion helper carve-outs

* fix(test): keep gauntlet subprocess startup lean

* fix: preserve imported session timestamp semantics

* fix: preserve catalog timestamp string semantics

* chore: align plugin SDK surface ratchet

* fix: preserve trajectory and SDK string contracts

* fix(test): preserve QA record assertion semantics

* fix: complete standalone record guard rename

* refactor(cron): use canonical string coercion

* fix(acpx): preserve Pi timestamp parsing

* test(channels): adapt custody test harnesses

* test(telegram): classify media harness as test support

* test(acpx): split timestamp contract coverage

* test(channels): support generated custody contracts

* chore: ban the full coercion helper name set

Extends the declaration guard to all eleven consolidated helper names and
renames the cron schedule-identity readNumber wrapper to readScheduleInteger
so the banned generic name cannot regrow.

* fix(scripts): repair release-validation guard drift and lint cause

Restores the renamed isJsonRecord guard in assertTrustedWorkflowHarness after
main added isRecord call sites in parallel, and attaches the caught YAML error
as the thrown error cause (preserve-caught-error was red on main).

* fix: preserve Claude timestamp string semantics

* fix: preserve persisted timestamp string semantics

* fix: preserve date-first timestamp contracts

* fix(openai): harden delegation failure formatting

* chore: close coercion helper guard gaps

* test(openai): model non-error delegation rejection

* chore: refresh plugin SDK API contract

* fix(tasks): use canonical string field reader

* fix(ai): use canonical provider error field coercion

* fix(browser): migrate native bootstrap coercion

* docs(plugin-sdk): clarify text record export compatibility

* fix(gateway): normalize approval execution identity

* test(outbound): isolate message action poll harness
2026-08-11 00:02:18 -07:00

454 lines
16 KiB
TypeScript

// Discord plugin module implements components.parse behavior.
import { ButtonStyle, TextInputStyle } from "discord-api-types/v10";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type {
DiscordComponentBlock,
DiscordComponentButtonSpec,
DiscordComponentButtonStyle,
DiscordComponentCallbackDataKind,
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 readRequiredString(
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 readOptionalCallbackDataKind(
value: unknown,
label: string,
): DiscordComponentCallbackDataKind | undefined {
const kind = normalizeOptionalString(value);
if (kind === undefined) {
return undefined;
}
if (kind === "command" || kind === "callback") {
return kind;
}
throw new Error(`${label} must be one of command, callback`);
}
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) => readRequiredString(entry, `${label}[${index}]`));
}
function readOptionalInteger(
value: unknown,
label: string,
bounds?: { min?: number; max?: number },
): number | undefined {
if (value == null) {
return undefined;
}
if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value)) {
throw new Error(`${label} must be an integer`);
}
if (bounds?.min !== undefined && value < bounds.min) {
throw new Error(`${label} must be at least ${bounds.min}`);
}
if (bounds?.max !== undefined && value > bounds.max) {
throw new Error(`${label} must be at most ${bounds.max}`);
}
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: readRequiredString(obj.name, `${label}.name`),
id: normalizeOptionalString(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;
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: readRequiredString(obj.label, `${label}[${index}].label`),
value: readRequiredString(obj.value, `${label}[${index}].value`),
description: normalizeOptionalString(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 = normalizeOptionalString(obj.style) as DiscordComponentButtonStyle | undefined;
const url = normalizeOptionalString(obj.url);
if ((style === "link" || url) && !url) {
throw new Error(`${label}.url is required for link buttons`);
}
return {
label: readRequiredString(obj.label, `${label}.label`),
style,
url,
callbackData: normalizeOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
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 = normalizeOptionalString(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: normalizeOptionalString(obj.callbackData),
callbackDataKind: readOptionalCallbackDataKind(
obj.callbackDataKind,
`${label}.callbackDataKind`,
),
placeholder: normalizeOptionalString(obj.placeholder),
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, { min: 0, max: 25 }),
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, { min: 1, max: 25 }),
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(
readRequiredString(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`);
}
if (type === "radio" && (obj.minValues != null || obj.maxValues != null)) {
throw new Error(`${label}.minValues/maxValues are not supported for radio fields`);
}
const required = typeof obj.required === "boolean" ? obj.required : undefined;
const maxValues = type === "checkbox" ? 10 : 25;
return {
type,
name: normalizeModalFieldName(normalizeOptionalString(obj.name), index),
label: readRequiredString(obj.label, `${label}.label`),
description: normalizeOptionalString(obj.description),
placeholder: normalizeOptionalString(obj.placeholder),
required,
options,
minValues: readOptionalInteger(obj.minValues, `${label}.minValues`, {
min: required === false ? 0 : 1,
max: maxValues,
}),
maxValues: readOptionalInteger(obj.maxValues, `${label}.maxValues`, {
min: 1,
max: maxValues,
}),
minLength: readOptionalInteger(obj.minLength, `${label}.minLength`, { min: 0, max: 4000 }),
maxLength: readOptionalInteger(obj.maxLength, `${label}.maxLength`, { min: 1, max: 4000 }),
style: normalizeOptionalString(obj.style) as DiscordModalFieldSpec["style"],
};
}
function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock {
const obj = requireObject(raw, label);
const typeRaw = normalizeLowercaseStringOrEmpty(readRequiredString(obj.type, `${label}.type`));
const type = normalizeBlockType(typeRaw);
switch (type) {
case "text":
return {
type: "text",
text: readRequiredString(obj.text, `${label}.text`),
};
case "section": {
const text = normalizeOptionalString(obj.text);
const textsRaw = obj.texts;
const texts = Array.isArray(textsRaw)
? textsRaw.map((entry, idx) => readRequiredString(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(
readRequiredString(accessoryObj.type, `${label}.accessory.type`),
);
if (accessoryType === "thumbnail") {
accessory = {
type: "thumbnail",
url: readRequiredString(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: readRequiredString(itemObj.url, `${label}.items[${idx}].url`),
description: normalizeOptionalString(itemObj.description),
spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined,
};
});
return {
type: "media-gallery",
items,
};
}
case "file": {
const file = readRequiredString(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: readRequiredString(modalObj.title, "components.modal.title"),
callbackData: normalizeOptionalString(modalObj.callbackData),
triggerLabel: normalizeOptionalString(modalObj.triggerLabel),
triggerStyle: normalizeOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
fields,
};
}
return {
text: normalizeOptionalString(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,
};
}