openpetswithchatandmcp/apps/desktop/src/plugin-sdk-validators.ts
2026-06-18 05:53:10 +00:00

568 lines
28 KiB
TypeScript

import type {
PluginAiRequest,
PluginBubbleAction,
PluginBubbleInput,
PluginCommand,
PluginCommandForm,
PluginCommandFormField,
PluginCommandIcon,
PluginMenuItem,
PluginReactOptions,
PluginStatus,
} from "./plugin-sdk-bridge.js";
import { validateSayMessage } from "./local-ipc-protocol.js";
import type { PluginAssetKind } from "./plugin-manifest.js";
import { pluginSdkQuotas } from "./plugin-sdk-quotas.js";
const quotas = pluginSdkQuotas;
export const commandIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
export const scheduleIdPattern = /^[A-Za-z0-9._:-]{1,64}$/;
export const allowedAccents = new Set(["blue", "purple", "green", "amber", "red", "pink", "slate"]);
export const namedHostIcons = new Set(["info", "check", "alert", "heart", "star", "bell", "coffee", "timer", "droplet", "sparkles", "zap", "moon", "sun", "food", "play", "pause"]);
const safeCssColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\)|hsla?\(\s*\d{1,3}(?:deg)?\s*,\s*\d{1,3}%\s*,\s*\d{1,3}%(?:\s*,\s*(?:0|1|0?\.\d+))?\s*\))$/;
const commandFormFieldTypes = new Set(["text", "textarea", "number", "boolean", "select", "multiSelect", "time", "date", "list"]);
type CronField = Set<number>;
type ParsedCron = {
minutes: CronField;
hours: CronField;
daysOfMonth: CronField;
months: CronField;
daysOfWeek: CronField;
domWildcard: boolean;
dowWildcard: boolean;
};
export function check(ok: boolean, message: string): void {
if (!ok) throw new Error(message);
}
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), max);
}
export function validateCssColor(value: unknown, message: string): string {
const color = String(value).trim();
check(color.length <= 48 && safeCssColorPattern.test(color), message);
return color;
}
export function validateStorageKey(key: string): string {
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(String(key))) throw new Error("Invalid plugin storage key.");
return String(key);
}
export function validatePetHandleId(value: unknown): string {
const id = String(value);
if (!/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new Error("Invalid familiar handle id.");
return id;
}
export function validateReactOptions(value: unknown): PluginReactOptions | undefined {
if (value === undefined) return undefined;
if (!isRecord(value)) throw new Error("Invalid familiar reaction options.");
const keys = Object.keys(value);
check(keys.every((key) => key === "showMessage"), "Invalid familiar reaction option.");
if (value.showMessage !== undefined && typeof value.showMessage !== "boolean") {
throw new Error("Invalid familiar reaction showMessage option.");
}
return value.showMessage === undefined ? {} : { showMessage: value.showMessage };
}
export function validatePoint(value: unknown): { x: number; y: number } {
if (!isRecord(value)) throw new Error("Invalid point.");
const x = Number(value.x);
const y = Number(value.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid point.");
return { x, y };
}
export function validateMoveToOptions(value: unknown): { durationMs?: number; easing?: string } {
const opts = isRecord(value) ? value : {};
const durationMs = opts.durationMs === undefined ? undefined : clampNumber(Number(opts.durationMs), 100, 10_000);
const easing = opts.easing === undefined
? undefined
: (check(["linear", "ease-in", "ease-out", "ease-in-out"].includes(String(opts.easing)), "Invalid easing."), String(opts.easing));
return { durationMs, easing };
}
export function validateDynamicText(value: string): string {
const text = value.replace(/[\0-\x08\x0B\x0C\x0E-\x1F]/g, "").trim();
check(text.length >= 1, "Dynamic speech cannot be empty.");
check(text.length <= quotas.dynamicTextChars, "Dynamic speech is too long.");
return text
.replace(/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/g, "[redacted]")
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, "[redacted]")
.replace(/-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g, "[redacted]")
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "[redacted]");
}
export function validatePinnedBubbleText(value: string): string {
const text = value.trim().replace(/\r\n?/g, "\n");
check(text.length >= 1, "Pinned bubble text cannot be empty.");
check(text.length <= 140, "Pinned bubble text is too long.");
const lines = text.split("\n");
check(lines.length <= 4, "Pinned bubble text has too many lines.");
check(lines.every((line) => line.trim().length > 0), "Pinned bubble text cannot contain blank lines.");
return lines.map((line) => validateSayMessage(line)).join("\n");
}
export function screenStaticBubbleText(markdown: string): void {
check(!/```|<script|function\s+\w+\(|\b(import|export)\s/.test(markdown), "Bubble markdown looks like code.");
check(!/https?:\/\/|www\./.test(markdown), "Bubble markdown contains a URL.");
check(!/(api[_-]?key|secret|password|BEGIN [A-Z ]+PRIVATE KEY)/i.test(markdown), "Bubble markdown looks secret-like.");
}
export function renderLimitedMarkdown(markdown: string): string {
const escaped = markdown
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
return escaped
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
.replace(/\n/g, "<br>");
}
export function validateBubbleActions(value: unknown): PluginBubbleAction[] {
check(Array.isArray(value) && value.length >= 1 && value.length <= 4, "Bubble actions must be 1-4 entries.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
throw new Error("Invalid bubble action id.");
}
seen.add(entry.id);
if (typeof entry.label !== "string" || entry.label.trim() === "" || entry.label.length > 32) {
throw new Error("Invalid bubble action label.");
}
const style = entry.style === undefined ? "default" : String(entry.style);
check(["default", "primary", "danger"].includes(style), "Invalid bubble action style.");
const iconName = entry.icon === undefined
? undefined
: (check(typeof entry.icon === "string" && namedHostIcons.has(entry.icon), "Invalid bubble action icon."), String(entry.icon));
return {
id: entry.id,
label: entry.label,
style: style as PluginBubbleAction["style"],
iconName,
dismissesBubble: entry.dismissesBubble !== false,
};
});
}
export function validateBubbleInput(value: unknown): PluginBubbleInput {
if (!isRecord(value) || typeof value.id !== "string" || !commandIdPattern.test(value.id)) {
throw new Error("Invalid bubble input id.");
}
const type = String(value.type);
check(["text", "number", "select"].includes(type), "Invalid bubble input type.");
const out: PluginBubbleInput = { id: value.id, type: type as PluginBubbleInput["type"] };
if (value.placeholder !== undefined) {
check(typeof value.placeholder === "string" && value.placeholder.length <= 60, "Invalid bubble input placeholder.");
out.placeholder = String(value.placeholder);
}
if (value.submitLabel !== undefined) {
check(typeof value.submitLabel === "string" && value.submitLabel.length <= 24 && value.submitLabel.trim() !== "", "Invalid bubble input submitLabel.");
out.submitLabel = String(value.submitLabel);
}
if (value.default !== undefined) {
check(typeof value.default === "string" ? value.default.length <= 200 : Number.isFinite(Number(value.default)), "Invalid bubble input default.");
out.default = typeof value.default === "string" ? value.default : Number(value.default);
}
if (type === "select") {
check(Array.isArray(value.options) && value.options.length >= 1 && value.options.length <= 8, "Bubble select inputs need 1-8 options.");
out.options = (value.options as unknown[]).map((option) => {
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 80 || typeof option.label !== "string" || option.label.length > 60) {
throw new Error("Invalid bubble input option.");
}
return { value: option.value, label: option.label };
});
}
return out;
}
export function validateMenuItems(value: unknown): PluginMenuItem[] {
check(Array.isArray(value) && value.length <= quotas.menuItems, "Invalid plugin menu items.");
const seen = new Set<string>();
return (value as unknown[]).map((entry) => {
if (!isRecord(entry) || typeof entry.id !== "string" || !commandIdPattern.test(entry.id) || seen.has(entry.id)) {
throw new Error("Invalid plugin menu item id.");
}
seen.add(entry.id);
if (typeof entry.title !== "string" || entry.title.trim() === "" || entry.title.length > 80) {
throw new Error("Invalid plugin menu item title.");
}
return {
id: entry.id,
title: entry.title,
enabled: entry.enabled === false ? false : undefined,
checked: entry.checked === true ? true : undefined,
};
});
}
export function validateAiRequest(value: unknown): PluginAiRequest {
if (!isRecord(value) || !Array.isArray(value.messages)) throw new Error("Invalid AI request.");
check(value.messages.length >= 1 && value.messages.length <= 64, "AI request needs 1-64 messages.");
const messages = (value.messages as unknown[]).map((entry) => {
if (!isRecord(entry) || (entry.role !== "user" && entry.role !== "assistant") || typeof entry.content !== "string") {
throw new Error("Invalid AI message.");
}
check(entry.content.length <= 32 * 1024, "AI message content is too long.");
return { role: entry.role as "user" | "assistant", content: entry.content };
});
const out: PluginAiRequest = { messages };
if (value.system !== undefined) {
check(typeof value.system === "string" && value.system.length <= 32 * 1024, "Invalid AI system prompt.");
out.system = String(value.system);
}
if (value.maxTokens !== undefined) out.maxTokens = clampNumber(Number(value.maxTokens), 1, 8192);
if (value.temperature !== undefined) out.temperature = clampNumber(Number(value.temperature), 0, 2);
if (value.tools !== undefined) {
check(Array.isArray(value.tools) && value.tools.length <= 16, "AI request allows at most 16 tools.");
out.tools = (value.tools as unknown[]).map((tool) => {
if (!isRecord(tool) || typeof tool.name !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(tool.name) || !isRecord(tool.inputSchema)) {
throw new Error("Invalid AI tool definition.");
}
const description = tool.description === undefined ? undefined : String(tool.description).slice(0, 1024);
return {
name: tool.name,
description,
inputSchema: normalizeJson(tool.inputSchema, 16 * 1024, "AI tool schema") as Record<string, unknown>,
};
});
}
return out;
}
export function validateOauthConfig(value: unknown): { provider: string; authorizationUrl: string; tokenUrl: string; clientId: string; scopes: string[]; pkce: boolean; redirect: "loopback" | "appProtocol" } {
if (!isRecord(value)) throw new Error("Invalid OAuth config.");
const authorizationUrl = new URL(String(value.authorizationUrl));
const tokenUrl = new URL(String(value.tokenUrl));
check(authorizationUrl.protocol === "https:" && tokenUrl.protocol === "https:", "OAuth URLs must be HTTPS.");
check(!authorizationUrl.username && !tokenUrl.username, "OAuth URLs must not carry credentials.");
const clientId = String(value.clientId ?? "");
check(clientId.length >= 1 && clientId.length <= 512 && !/[\s\0]/.test(clientId), "Invalid OAuth clientId.");
check(Array.isArray(value.scopes) && value.scopes.length <= 32, "Invalid OAuth scopes.");
const scopes = (value.scopes as unknown[]).map((scope) => {
const text = String(scope);
check(text.length >= 1 && text.length <= 256 && !/[\r\n\0]/.test(text), "Invalid OAuth scope.");
return text;
});
const provider = value.provider === undefined ? "generic" : validateProviderName(value.provider);
const redirect = value.redirect === undefined ? "loopback" : String(value.redirect);
check(redirect === "loopback" || redirect === "appProtocol", "Invalid OAuth redirect mode.");
return {
provider,
authorizationUrl: authorizationUrl.toString(),
tokenUrl: tokenUrl.toString(),
clientId,
scopes,
pkce: value.pkce !== false,
redirect: redirect as "loopback" | "appProtocol",
};
}
export function normalizeJson(value: unknown, maxBytes: number, label: string): unknown {
let text: string;
try {
text = JSON.stringify(value ?? null);
} catch {
throw new Error(`Plugin ${label} must be JSON-compatible.`);
}
if (text === undefined) throw new Error(`Plugin ${label} must be JSON-compatible.`);
check(Buffer.byteLength(text) <= maxBytes, `Plugin ${label} is too large.`);
return JSON.parse(text) as unknown;
}
export function validateCommand(command: PluginCommand, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommand {
if (!command || !commandIdPattern.test(command.id)) throw new Error("Invalid plugin command id.");
if (typeof command.title !== "string" || command.title.trim() === "" || command.title.length > 80) throw new Error("Invalid plugin command title.");
if (command.description !== undefined && (typeof command.description !== "string" || command.description.length > 240)) {
throw new Error("Invalid plugin command description.");
}
const placement = command.placement === undefined
? undefined
: (check(command.placement === "top" || command.placement === "submenu", "Invalid plugin command placement."), command.placement);
const priority = command.priority === undefined
? undefined
: (check(Number.isFinite(Number(command.priority)), "Invalid plugin command priority."), clampNumber(Number(command.priority), -1000, 1000));
const icon = command.icon === undefined ? undefined : validateCommandIcon(command.icon, validateIconAssetRef);
return {
id: command.id,
title: command.title,
description: command.description,
form: validateCommandForm(command.form),
placement,
priority,
featured: command.featured === true || undefined,
icon,
};
}
export function validateCommandFormValues(form: PluginCommandForm, args: unknown): Record<string, unknown> {
const input = isRecord(args) ? args : {};
const out: Record<string, unknown> = {};
for (const field of form.fields) {
const raw = input[field.id];
if (field.type === "number") {
const value = Number(raw ?? field.default ?? 0);
if (!Number.isFinite(value)) throw new Error(`${field.label} must be a number.`);
if (field.min !== undefined && value < field.min) throw new Error(`${field.label} is too small.`);
if (field.max !== undefined && value > field.max) throw new Error(`${field.label} is too large.`);
out[field.id] = value;
} else if (field.type === "boolean") {
out[field.id] = raw === undefined ? field.default === true : raw === true || raw === "true";
} else if (field.type === "select") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !(field.options ?? []).some((option) => option.value === value)) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = value;
} else if (field.type === "multiSelect") {
const values = Array.isArray(raw) ? raw.map(String) : Array.isArray(field.default) ? field.default : [];
const allowed = new Set((field.options ?? []).map((option) => option.value));
if (values.some((value) => !allowed.has(value))) throw new Error(`${field.label} has an invalid value.`);
out[field.id] = values;
} else if (field.type === "time") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^([01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error(`${field.label} must be HH:mm.`);
out[field.id] = value;
} else if (field.type === "date") {
const value = String(raw ?? field.default ?? "").trim();
if (field.required && !value) throw new Error(`${field.label} is required.`);
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error(`${field.label} must be YYYY-MM-DD.`);
out[field.id] = value;
} else if (field.type === "list") {
const values = Array.isArray(raw) ? raw.map((entry) => String(entry).trim()).filter(Boolean) : Array.isArray(field.default) ? field.default : [];
if (values.length > 32) throw new Error(`${field.label} has too many entries.`);
const maxLength = field.maxLength;
if (maxLength !== undefined && values.some((value) => value.length > maxLength)) throw new Error(`${field.label} entries are too long.`);
out[field.id] = values;
} else {
const text = String(raw ?? field.default ?? "").trim();
if (field.required && !text) throw new Error(`${field.label} is required.`);
if (field.maxLength !== undefined && text.length > field.maxLength) throw new Error(`${field.label} is too long.`);
out[field.id] = text;
}
}
return out;
}
export function validateStatus(status: PluginStatus | string): PluginStatus {
const value = typeof status === "string" ? { text: status } : status;
if (!value || typeof value.text !== "string" || value.text.trim() === "" || value.text.length > 120) {
throw new Error("Invalid plugin status text.");
}
if (value.tone !== undefined && !["info", "success", "warning", "error"].includes(value.tone)) {
throw new Error("Invalid plugin status tone.");
}
return { text: value.text, tone: value.tone };
}
export function validateMoveBy(value: unknown): { x: number; y: number; durationMs?: number } {
if (!isRecord(value)) throw new Error("Invalid familiar movement options.");
const x = Number(value.x);
const y = Number(value.y);
if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error("Invalid familiar movement distance.");
return { x, y, durationMs: value.durationMs === undefined ? undefined : Number(value.durationMs) };
}
export function validateWander(value: unknown): { distance?: number; durationMs?: number } {
const options = isRecord(value) ? value : {};
return {
distance: options.distance === undefined ? undefined : Number(options.distance),
durationMs: options.durationMs === undefined ? undefined : Number(options.durationMs),
};
}
export function parseCronExpression(expr: string): ParsedCron {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) throw new Error("Cron expressions must have 5 fields (m h dom mon dow).");
const [minutePart, hourPart, domPart, monthPart, dowPart] = parts as [string, string, string, string, string];
return {
minutes: parseCronField(minutePart, 0, 59),
hours: parseCronField(hourPart, 0, 23),
daysOfMonth: parseCronField(domPart, 1, 31),
months: parseCronField(monthPart, 1, 12),
daysOfWeek: parseCronField(dowPart, 0, 7, true),
domWildcard: domPart === "*",
dowWildcard: dowPart === "*",
};
}
export function nextCronRunMs(expr: string, fromMs: number): number | null {
const cron = parseCronExpression(expr);
const candidate = new Date(fromMs);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
while (candidate.getTime() <= limit) {
if (!cron.months.has(candidate.getMonth() + 1)) {
candidate.setMonth(candidate.getMonth() + 1, 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
const domMatch = cron.daysOfMonth.has(candidate.getDate());
const dowMatch = cron.daysOfWeek.has(candidate.getDay());
const dayMatch = cron.domWildcard && cron.dowWildcard ? true : cron.domWildcard ? dowMatch : cron.dowWildcard ? domMatch : domMatch || dowMatch;
if (!dayMatch) {
candidate.setDate(candidate.getDate() + 1);
candidate.setHours(0, 0, 0, 0);
continue;
}
if (!cron.hours.has(candidate.getHours())) {
candidate.setHours(candidate.getHours() + 1, 0, 0, 0);
continue;
}
if (!cron.minutes.has(candidate.getMinutes())) {
candidate.setMinutes(candidate.getMinutes() + 1, 0, 0);
continue;
}
return candidate.getTime();
}
return null;
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function validateProviderName(value: unknown): string {
const provider = String(value);
check(/^[a-z0-9][a-z0-9._-]{0,63}$/.test(provider), "Invalid OAuth provider name.");
return provider;
}
function validateCommandIcon(icon: unknown, validateIconAssetRef: (ref: unknown) => { kind: PluginAssetKind; name: string; path: string }): PluginCommandIcon {
if (typeof icon === "string") {
check(namedHostIcons.has(icon), "Invalid plugin command icon.");
return icon;
}
if (!isRecord(icon) || icon.kind !== "icon" || typeof icon.name !== "string") throw new Error("Invalid plugin command icon.");
validateIconAssetRef(icon);
return { kind: "icon", name: icon.name };
}
function validateCommandForm(form: unknown): PluginCommandForm | undefined {
if (form === undefined) return undefined;
if (!isRecord(form) || !Array.isArray(form.fields) || form.fields.length < 1 || form.fields.length > 8) {
throw new Error("Invalid plugin command form.");
}
const seen = new Set<string>();
const fields = form.fields.map((field) => {
if (!isRecord(field) || typeof field.id !== "string" || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(field.id) || seen.has(field.id)) {
throw new Error("Invalid plugin command form field id.");
}
seen.add(field.id);
if (!commandFormFieldTypes.has(String(field.type))) throw new Error("Invalid plugin command form field type.");
if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > 80) {
throw new Error("Invalid plugin command form label.");
}
const out: PluginCommandFormField = {
id: field.id,
type: field.type as PluginCommandFormField["type"],
label: field.label,
required: field.required === true || undefined,
};
if (out.type === "number") {
if (field.default !== undefined && !Number.isFinite(Number(field.default))) throw new Error("Invalid plugin command form default.");
if (field.min !== undefined && !Number.isFinite(Number(field.min))) throw new Error("Invalid plugin command form min.");
if (field.max !== undefined && !Number.isFinite(Number(field.max))) throw new Error("Invalid plugin command form max.");
if (field.min !== undefined) out.min = Number(field.min);
if (field.max !== undefined) out.max = Number(field.max);
if (out.min !== undefined && out.max !== undefined && out.min > out.max) throw new Error("Invalid plugin command form range.");
if (field.default !== undefined) out.default = Number(field.default);
} else if (out.type === "boolean") {
if (field.default !== undefined && typeof field.default !== "boolean") throw new Error("Invalid plugin command form default.");
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "select" || out.type === "multiSelect") {
if (!Array.isArray(field.options) || field.options.length < 1 || field.options.length > 24) throw new Error("Invalid plugin command form options.");
const values = new Set<string>();
out.options = field.options.map((option) => {
if (!isRecord(option) || typeof option.value !== "string" || option.value.length > 120 || typeof option.label !== "string" || option.label.trim() === "" || option.label.length > 80 || values.has(option.value)) {
throw new Error("Invalid plugin command form option.");
}
values.add(option.value);
return { label: option.label, value: option.value };
});
if (field.default !== undefined) {
if (out.type === "multiSelect") {
if (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || !values.has(entry))) {
throw new Error("Invalid plugin command form default.");
}
out.default = field.default as string[];
} else {
if (typeof field.default !== "string" || !values.has(field.default)) throw new Error("Invalid plugin command form default.");
out.default = field.default;
}
}
} else if (out.type === "time") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(field.default))) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "date") {
if (field.default !== undefined && (typeof field.default !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(field.default))) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default;
} else if (out.type === "list") {
if (field.default !== undefined && (!Array.isArray(field.default) || field.default.some((entry) => typeof entry !== "string" || entry.length > 200) || field.default.length > 32)) {
throw new Error("Invalid plugin command form default.");
}
if (field.default !== undefined) out.default = field.default as string[];
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) {
throw new Error("Invalid plugin command form maxLength.");
}
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
} else {
if (field.default !== undefined && typeof field.default !== "string") throw new Error("Invalid plugin command form default.");
if (field.maxLength !== undefined && (!Number.isInteger(Number(field.maxLength)) || Number(field.maxLength) < 1 || Number(field.maxLength) > 1000)) {
throw new Error("Invalid plugin command form maxLength.");
}
if (field.default !== undefined) out.default = field.default;
if (field.maxLength !== undefined) out.maxLength = Number(field.maxLength);
}
return out;
});
const submitLabel = typeof form.submitLabel === "string" && form.submitLabel.trim() && form.submitLabel.length <= 40 ? form.submitLabel : undefined;
return { fields, submitLabel };
}
function parseCronField(field: string, min: number, max: number, mapSevenToZero = false): CronField {
const values = new Set<number>();
if (field.length === 0 || field.length > 64) throw new Error("Invalid cron field.");
for (const part of field.split(",")) {
const stepMatch = /^(.+)\/(\d+)$/.exec(part);
const base = stepMatch ? stepMatch[1]! : part;
const step = stepMatch ? Number(stepMatch[2]) : 1;
if (!Number.isInteger(step) || step < 1 || step > max) throw new Error("Invalid cron step.");
let start = min;
let end = max;
if (base !== "*") {
const rangeMatch = /^(\d+)-(\d+)$/.exec(base);
if (rangeMatch) {
start = Number(rangeMatch[1]);
end = Number(rangeMatch[2]);
} else {
if (!/^\d+$/.test(base)) throw new Error("Invalid cron value.");
start = Number(base);
end = stepMatch ? max : start;
}
}
if (start < min || end > max || start > end) throw new Error("Cron value out of range.");
for (let value = start; value <= end; value += step) {
values.add(mapSevenToZero && value === 7 ? 0 : value);
}
}
if (values.size === 0) throw new Error("Invalid cron field.");
return values;
}