chore: initial commit
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import {readdir} from 'node:fs/promises';
|
||||
import type {Bot} from '@wp2508/mineflayer';
|
||||
import type {BaseFunction, FunctionArgument, FunctionI18n} from '../../types/bot/function/function.ts';
|
||||
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
|
||||
import type {bot_instance_context} from '../instance/bot_instance_init.ts';
|
||||
import {create_function_cancellation, on_function_cancel} from './cancellation.ts';
|
||||
import {measure_sync} from '../info/resource_metrics.ts';
|
||||
|
||||
export interface FunctionTemplate {
|
||||
name: string;
|
||||
i18n: FunctionI18n;
|
||||
args: FunctionArgument[];
|
||||
}
|
||||
|
||||
export interface FunctionCategory {
|
||||
path: string;
|
||||
name: string;
|
||||
i18n: FunctionI18n[];
|
||||
function_list: FunctionTemplate[];
|
||||
}
|
||||
|
||||
export type FunctionList = FunctionCategory[];
|
||||
|
||||
export interface FunctionManager {
|
||||
get_function_list(): FunctionList;
|
||||
run(path: string, name: string, args: unknown[], before_run?: () => void,
|
||||
options?: {signal?: AbortSignal}): Promise<boolean>;
|
||||
/** Cancels queued calls and notifies the running call, which drains before rejecting. */
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const data_types = new Set<data_type>([
|
||||
'string', 'boolean', 'number', 'datetime', 'string-select', 'number-select',
|
||||
'string-multi-select', 'number-multi-select', 'string-array', 'number-array',
|
||||
]);
|
||||
const valid_name = (value: unknown): value is string => typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value);
|
||||
const valid_i18n = (value: unknown): value is FunctionI18n => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const item = value as FunctionI18n;
|
||||
return typeof item.lang === 'string' && item.lang.length > 0
|
||||
&& typeof item.name === 'string' && typeof item.description === 'string';
|
||||
};
|
||||
|
||||
async function load_functions(check_active: () => void = () => {}): Promise<{
|
||||
list: FunctionList;
|
||||
functions: Map<string, BaseFunction>;
|
||||
}> {
|
||||
const list: FunctionList = [];
|
||||
const functions = new Map<string, BaseFunction>();
|
||||
const directories = [''];
|
||||
const stamp = `${Date.now()}-${process.hrtime.bigint()}`;
|
||||
const import_module = async (url: URL): Promise<Record<string, unknown>> => {
|
||||
check_active();
|
||||
url.searchParams.set('t', stamp);
|
||||
const module = await import(url.href);
|
||||
check_active();
|
||||
return module;
|
||||
};
|
||||
for (const path of directories) {
|
||||
check_active();
|
||||
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
|
||||
const entries = await readdir(directory, {withFileTypes: true});
|
||||
check_active();
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && valid_name(entry.name)) directories.push(path ? `${path}/${entry.name}` : entry.name);
|
||||
}
|
||||
if (!path) continue;
|
||||
const files = entries.filter(entry => entry.isFile() && entry.name !== 'define.ts'
|
||||
&& /^[a-zA-Z0-9_-]+\.ts$/.test(entry.name));
|
||||
const has_define = entries.some(entry => entry.isFile() && entry.name === 'define.ts');
|
||||
if (!files.length && !has_define) continue;
|
||||
if (!has_define) throw new Error(`函数分类 ${path} 缺少 define.ts`);
|
||||
try {
|
||||
const {define} = await import_module(new URL('define.ts', directory));
|
||||
const config = define as {path?: unknown; name?: unknown; i18n?: unknown} | undefined;
|
||||
if (config?.path !== path || !Array.isArray(config.i18n) || !config.i18n.length
|
||||
|| !config.i18n.every(valid_i18n) || config.name !== undefined && !valid_name(config.name)) {
|
||||
throw new Error('分类 define 无效,path 必须与目录路径一致');
|
||||
}
|
||||
const category: FunctionCategory = {
|
||||
path, name: typeof config.name === 'string' ? config.name : path.split('/').at(-1)!,
|
||||
i18n: config.i18n.map(item => ({...item})), function_list: [],
|
||||
};
|
||||
for (const file of files) {
|
||||
const name = file.name.slice(0, -3);
|
||||
try {
|
||||
const module = await import_module(new URL(file.name, directory));
|
||||
if (!Object.hasOwn(module, 'init')) continue;
|
||||
if (typeof module.init !== 'function') throw new Error('必须导出 init 类');
|
||||
const instance = new (module.init as new () => BaseFunction)();
|
||||
if (instance.name !== name || !valid_i18n(instance.i18n)
|
||||
|| !Array.isArray(instance.args) || typeof instance.run !== 'function') {
|
||||
throw new Error('函数必须满足 BaseFunction 且 name 与文件名一致');
|
||||
}
|
||||
const names = new Set<string>();
|
||||
let optional = false;
|
||||
for (const arg of instance.args) {
|
||||
if (!arg || !valid_name(arg.name) || names.has(arg.name)
|
||||
|| !data_types.has(arg.type) || !valid_i18n(arg.i18n)) {
|
||||
throw new Error('函数参数定义无效或名称重复');
|
||||
}
|
||||
if (Object.hasOwn(arg, 'default')) {
|
||||
if (arg.type !== 'boolean' || typeof arg.default !== 'boolean') throw new Error('默认值必须匹配布尔参数');
|
||||
optional = true;
|
||||
} else if (optional) throw new Error('带默认值的参数必须位于末尾');
|
||||
names.add(arg.name);
|
||||
}
|
||||
const key = `${path}/${name}`;
|
||||
if (functions.has(key)) throw new Error('函数重复注册');
|
||||
functions.set(key, instance);
|
||||
category.function_list.push({name, i18n: {...instance.i18n},
|
||||
args: instance.args.map(arg => ({name: arg.name, type: arg.type, i18n: {...arg.i18n},
|
||||
...(Object.hasOwn(arg, 'default') ? {default: arg.default} : {})}))});
|
||||
} catch (error) {
|
||||
throw new Error(`函数 ${path}/${name} 加载失败`, {cause: error});
|
||||
}
|
||||
}
|
||||
list.push(category);
|
||||
} catch (error) {
|
||||
throw new Error(`函数分类 ${path} 加载失败`, {cause: error});
|
||||
}
|
||||
}
|
||||
list.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return {list, functions};
|
||||
}
|
||||
|
||||
/** Builds grouped metadata without a Bot; constructors must have no side effects. */
|
||||
export async function get_function_list(): Promise<FunctionList> {
|
||||
return (await load_functions()).list;
|
||||
}
|
||||
|
||||
function valid_argument(value: unknown, type: data_type): boolean {
|
||||
switch (type) {
|
||||
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
|
||||
case 'string': case 'string-select': return typeof value === 'string';
|
||||
case 'boolean': return typeof value === 'boolean';
|
||||
case 'datetime': return typeof value === 'string'
|
||||
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value) && Number.isFinite(Date.parse(value));
|
||||
case 'string-array': case 'string-multi-select':
|
||||
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'string');
|
||||
case 'number-array': case 'number-multi-select':
|
||||
return Array.isArray(value) && Array.from(value).every(item => typeof item === 'number' && Number.isFinite(item));
|
||||
}
|
||||
}
|
||||
|
||||
/** The caller owns disposal; this manager never registers end listeners or follows a replacement Bot. */
|
||||
export async function create_function_manager(
|
||||
context: Pick<bot_instance_context<unknown>, 'bot_instance' | 'bot_status'>
|
||||
& Partial<Pick<bot_instance_context<unknown>, 'workflow_manager'>>,
|
||||
): Promise<FunctionManager> {
|
||||
const bot: Bot | null = context.bot_instance;
|
||||
if (!bot) throw new Error('Bot 实例尚未创建');
|
||||
const check_instance = () => {
|
||||
if (context.bot_instance !== bot) throw new Error('Bot 实例已变化');
|
||||
};
|
||||
const {list, functions} = await load_functions(check_instance);
|
||||
let disposed = false;
|
||||
let tail = Promise.resolve();
|
||||
const pending = new Set<{reject: (error: unknown) => void; resolve: (value: boolean) => void}>();
|
||||
let cancel_running: ((reason: unknown) => void) | undefined;
|
||||
const check_active = () => {
|
||||
if (disposed) throw new Error('Function manager 已销毁');
|
||||
check_instance();
|
||||
};
|
||||
return {
|
||||
get_function_list() {
|
||||
check_active();
|
||||
return structuredClone(list);
|
||||
},
|
||||
async run(path, name, args, before_run, options) {
|
||||
const signal = options?.signal;
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
const template = list.find(category => category.path === path)?.function_list.find(item => item.name === name);
|
||||
const instance = template && functions.get(`${path}/${name}`);
|
||||
if (!instance || !template) throw new Error('函数不存在');
|
||||
if (!Array.isArray(args) || args.length > template.args.length
|
||||
|| !template.args.every((arg, index) => index < args.length
|
||||
? valid_argument(args[index], arg.type) : Object.hasOwn(arg, 'default'))) {
|
||||
throw new Error('函数参数数量或类型不正确');
|
||||
}
|
||||
const values = structuredClone(template.args.map((arg, index) => index < args.length ? args[index] : arg.default));
|
||||
if (path === 'workflow' && instance.execution === 'workflow_control') {
|
||||
const workflow = context.workflow_manager;
|
||||
const check_control = () => {
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
if (context.workflow_manager !== workflow) throw new Error('Workflow manager 已变化');
|
||||
};
|
||||
before_run?.();
|
||||
check_control();
|
||||
if (!workflow) return false;
|
||||
// Bind only the control capability to this dispatch, never the raw Bot context.
|
||||
let result = false;
|
||||
try {
|
||||
result = await measure_sync(bot, () => instance.run(bot, values, {workflow: {
|
||||
control(request) {
|
||||
check_control();
|
||||
const command = workflow.control(request);
|
||||
check_control();
|
||||
return command;
|
||||
},
|
||||
}})) === true;
|
||||
} catch { /* Function failures retain the boolean API. */ }
|
||||
check_control();
|
||||
return result;
|
||||
}
|
||||
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
|
||||
if (path === 'bot' && name === 'stop_current_function') {
|
||||
before_run?.();
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
for (const task of pending) task.resolve(false);
|
||||
pending.clear();
|
||||
const result = await measure_sync(bot, () => instance.run(bot, values));
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
if (pending.size >= 64) throw new Error('函数等待队列已满');
|
||||
return new Promise<boolean>((resolve, reject) => {
|
||||
let cancel_execution: ((reason: unknown) => void) | undefined;
|
||||
const cleanup = () => signal?.removeEventListener('abort', abort);
|
||||
const task = {
|
||||
reject(error: unknown) { cleanup(); reject(error); },
|
||||
resolve(value: boolean) { cleanup(); resolve(value); },
|
||||
};
|
||||
const abort = () => {
|
||||
if (pending.delete(task)) task.reject(signal!.reason);
|
||||
else cancel_execution?.(signal!.reason);
|
||||
};
|
||||
pending.add(task);
|
||||
signal?.addEventListener('abort', abort, {once: true});
|
||||
const execute = async () => {
|
||||
if (!pending.delete(task)) return;
|
||||
try {
|
||||
check_active();
|
||||
if (context.bot_status !== 'online') throw new Error('Bot 当前不在线');
|
||||
signal?.throwIfAborted();
|
||||
before_run?.();
|
||||
signal?.throwIfAborted();
|
||||
check_active();
|
||||
const cancellation = create_function_cancellation(bot);
|
||||
let interrupted = false;
|
||||
let reason: unknown;
|
||||
cancel_running = cancel_execution = error => {
|
||||
if (interrupted) return;
|
||||
interrupted = true;
|
||||
reason = error;
|
||||
cancellation.cancel();
|
||||
};
|
||||
try {
|
||||
await cancellation.run(async () => {
|
||||
// Global stop may answer early, but the queue still drains instance.run.
|
||||
const unwatch = on_function_cancel(bot, () => {
|
||||
if (!interrupted) task.resolve(false);
|
||||
});
|
||||
let result = false;
|
||||
try { result = await measure_sync(bot, () => instance.run(bot, values)) === true; }
|
||||
catch { /* Function failures retain the boolean API. */ }
|
||||
finally { unwatch(); }
|
||||
if (interrupted) task.reject(reason);
|
||||
else task.resolve(result);
|
||||
});
|
||||
} finally {
|
||||
cleanup();
|
||||
cancel_running = cancel_execution = undefined;
|
||||
cancellation.dispose();
|
||||
}
|
||||
} catch (error) { task.reject(error); }
|
||||
};
|
||||
tail = tail.then(execute, execute);
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
for (const task of pending) task.reject(new Error('Function manager 已销毁,排队调用已取消'));
|
||||
pending.clear();
|
||||
functions.clear();
|
||||
cancel_running?.(new Error('Function manager 已销毁,当前调用已取消'));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user