568 lines
28 KiB
TypeScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll("\"", """)
|
|
.replaceAll("'", "'");
|
|
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;
|
|
}
|