|
|
|
@@ -0,0 +1,346 @@
|
|
|
|
|
import {readdir} from 'node:fs/promises';
|
|
|
|
|
import {isDeepStrictEqual} from 'node:util';
|
|
|
|
|
import type {BasePlugin, PluginContext, base_define, bot_config_plugin, plugin} from '../../types/bot/plugins/plugin.ts';
|
|
|
|
|
import type {data_type} from '../../types/bot/instance/bot_runtime.ts';
|
|
|
|
|
import {PluginError} from './error.ts';
|
|
|
|
|
import {plain_plugin_object, valid_plugin_config, valid_plugin_name, valid_plugin_path} from '../../utils/plugin_validation.ts';
|
|
|
|
|
export {PluginError} from './error.ts';
|
|
|
|
|
|
|
|
|
|
const valid_name = valid_plugin_name;
|
|
|
|
|
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_i18n = (value: unknown): boolean => Array.isArray(value) && value.length > 0 && value.every(item =>
|
|
|
|
|
item && typeof item.lang === 'string' && item.lang.length > 0 && typeof item.name === 'string' && typeof item.description === 'string');
|
|
|
|
|
|
|
|
|
|
function valid_value(value: unknown, type: data_type): boolean {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'string': case 'string-select': return typeof value === 'string';
|
|
|
|
|
case 'number': case 'number-select': return typeof value === 'number' && Number.isFinite(value);
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validate_item(item: bot_config_plugin<unknown>) {
|
|
|
|
|
if (!plain_plugin_object(item) || !valid_name(item.name) || !valid_plugin_path(item.path)
|
|
|
|
|
|| item.config !== undefined && !valid_plugin_config(item.config)) {
|
|
|
|
|
throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function import_module(url: URL): Promise<Record<string, unknown>> {
|
|
|
|
|
url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`);
|
|
|
|
|
return import(url.href);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function instantiate(module: Record<string, unknown>, name: string): BasePlugin {
|
|
|
|
|
if (typeof module.init !== 'function') throw new Error(`Plugin ${name} must export an init class`);
|
|
|
|
|
const instance = new (module.init as new () => BasePlugin)();
|
|
|
|
|
const template = instance.config_template;
|
|
|
|
|
if (instance.name !== name || template?.name !== name || !valid_i18n(template.i18n)
|
|
|
|
|
|| typeof template.version !== 'string' || !template.version.length || !Array.isArray(template.config)
|
|
|
|
|
|| typeof instance.init !== 'function' || typeof instance.get_status !== 'function'
|
|
|
|
|
|| typeof instance.on_states_update !== 'function' || typeof instance.dispose !== 'function') {
|
|
|
|
|
throw new Error(`Invalid plugin metadata: ${name}`);
|
|
|
|
|
}
|
|
|
|
|
const names = new Set<string>();
|
|
|
|
|
for (const field of template.config) {
|
|
|
|
|
if (!field || !valid_name(field.name) || names.has(field.name) || !valid_i18n(field.i18n)
|
|
|
|
|
|| typeof field.required !== 'boolean' || !data_types.has(field.type) || !Object.hasOwn(field, 'default')
|
|
|
|
|
|| field.default != null && !valid_value(field.default, field.type)) throw new Error(`Invalid plugin config metadata: ${name}`);
|
|
|
|
|
names.add(field.name);
|
|
|
|
|
}
|
|
|
|
|
return instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolve_config(template: plugin, input: Record<string, unknown>): Record<string, unknown> {
|
|
|
|
|
const names = new Set(template.config.map(field => field.name));
|
|
|
|
|
for (const key of Object.keys(input)) if (!names.has(key)) throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
const config: Record<string, unknown> = {};
|
|
|
|
|
for (const field of template.config) {
|
|
|
|
|
const value = Object.hasOwn(input, field.name) ? input[field.name] : field.default;
|
|
|
|
|
if (value == null ? field.required : !valid_value(value, field.type)) throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
Object.defineProperty(config, field.name, {value: structuredClone(value), enumerable: true, writable: true, configurable: true});
|
|
|
|
|
}
|
|
|
|
|
return config;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface PluginSnapshot {
|
|
|
|
|
name: string;
|
|
|
|
|
path: string;
|
|
|
|
|
config: Record<string, unknown>;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
lifecycle: 'initializing' | 'enabled' | 'disabling' | 'disabled' | 'error';
|
|
|
|
|
/** Last observed internal state; disabled records retain the final disposal snapshot. */
|
|
|
|
|
states: unknown;
|
|
|
|
|
error?: {code: string; message: string};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Entry = {instance: BasePlugin; path: string; config: Record<string, unknown>; unsubscribe?: () => void; quarantined?: boolean};
|
|
|
|
|
|
|
|
|
|
export class PluginManager {
|
|
|
|
|
private readonly entries = new Map<string, Entry>();
|
|
|
|
|
private readonly records = new Map<string, PluginSnapshot>();
|
|
|
|
|
private readonly listeners = new Set<(event: PluginSnapshot) => void>();
|
|
|
|
|
private tail: Promise<void> = Promise.resolve();
|
|
|
|
|
private disposed = false;
|
|
|
|
|
private dispose_task?: Promise<void>;
|
|
|
|
|
|
|
|
|
|
constructor(private readonly context: PluginContext) {}
|
|
|
|
|
|
|
|
|
|
on_update(callback: (event: PluginSnapshot) => void): () => void {
|
|
|
|
|
this.check_active();
|
|
|
|
|
const listener = (event: PluginSnapshot) => callback(event);
|
|
|
|
|
this.listeners.add(listener);
|
|
|
|
|
return () => { this.listeners.delete(listener); };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private publish(record: PluginSnapshot) {
|
|
|
|
|
this.records.set(record.name, structuredClone(record));
|
|
|
|
|
for (const listener of Array.from(this.listeners)) {
|
|
|
|
|
if (!this.listeners.has(listener)) continue;
|
|
|
|
|
try { listener(structuredClone(record)); } catch {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private capture(entry: Entry): unknown {
|
|
|
|
|
try { return structuredClone(entry.instance.get_status()); }
|
|
|
|
|
catch { return null; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private transition(entry: Entry, lifecycle: PluginSnapshot['lifecycle']) {
|
|
|
|
|
this.publish({name: entry.instance.name, path: entry.path, config: entry.config,
|
|
|
|
|
enabled: lifecycle === 'enabled', lifecycle, states: this.capture(entry),
|
|
|
|
|
...(entry.quarantined ? {error: {code: 'DISPOSE_FAILED', message: 'Plugin disposal failed'}} : {})});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private check_active() {
|
|
|
|
|
if (this.disposed) throw new PluginError('MANAGER_DISPOSED');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private enqueue(operation: () => Promise<void>): Promise<void> {
|
|
|
|
|
if (this.disposed) return Promise.reject(new PluginError('MANAGER_DISPOSED'));
|
|
|
|
|
const task = this.tail.then(async () => { this.check_active(); await operation(); });
|
|
|
|
|
this.tail = task.catch(() => {});
|
|
|
|
|
return task;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async prepare(item: bot_config_plugin<unknown>): Promise<Entry> {
|
|
|
|
|
validate_item(item);
|
|
|
|
|
if (this.entries.get(item.name)?.quarantined) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
let instance: BasePlugin;
|
|
|
|
|
try {
|
|
|
|
|
const module = await import_module(new URL(`./${item.path}/${item.name}.ts`, import.meta.url));
|
|
|
|
|
this.check_active();
|
|
|
|
|
instance = instantiate(module, item.name);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof PluginError) throw error;
|
|
|
|
|
throw new PluginError('LOAD_FAILED');
|
|
|
|
|
}
|
|
|
|
|
return {instance, path: item.path, config: resolve_config(instance.config_template, (item.config ?? {}) as Record<string, unknown>)};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async install(entry: Entry) {
|
|
|
|
|
try {
|
|
|
|
|
this.check_active();
|
|
|
|
|
this.transition(entry, 'initializing');
|
|
|
|
|
entry.unsubscribe = entry.instance.on_states_update(() => {
|
|
|
|
|
const record = this.records.get(entry.instance.name);
|
|
|
|
|
if (record) this.transition(entry, record.lifecycle);
|
|
|
|
|
});
|
|
|
|
|
this.check_active();
|
|
|
|
|
await entry.instance.init(this.context, structuredClone(entry.config));
|
|
|
|
|
this.check_active();
|
|
|
|
|
this.entries.set(entry.instance.name, entry);
|
|
|
|
|
this.transition(entry, 'enabled');
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.entries.delete(entry.instance.name);
|
|
|
|
|
this.transition(entry, 'disabling');
|
|
|
|
|
let cleanup_failed = false;
|
|
|
|
|
try { await entry.instance.dispose(); }
|
|
|
|
|
catch { cleanup_failed = true; }
|
|
|
|
|
finally {
|
|
|
|
|
try { entry.unsubscribe?.(); } catch { cleanup_failed = true; }
|
|
|
|
|
if (cleanup_failed) {
|
|
|
|
|
entry.quarantined = true;
|
|
|
|
|
this.entries.set(entry.instance.name, entry);
|
|
|
|
|
}
|
|
|
|
|
this.transition(entry, 'error');
|
|
|
|
|
}
|
|
|
|
|
if (cleanup_failed) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
throw error instanceof PluginError ? error : new PluginError('INIT_FAILED');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
apply_desired(plugins: bot_config_plugin<unknown>[]): Promise<void> {
|
|
|
|
|
return this.enqueue(async () => {
|
|
|
|
|
if (!Array.isArray(plugins)) throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
const desired = new Map<string, bot_config_plugin<unknown>>();
|
|
|
|
|
for (const item of plugins) {
|
|
|
|
|
validate_item(item);
|
|
|
|
|
if (desired.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
|
|
|
|
|
const current = this.entries.get(item.name);
|
|
|
|
|
if (current && current.path !== item.path) throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
desired.set(item.name, structuredClone(item));
|
|
|
|
|
}
|
|
|
|
|
for (const entry of this.entries.values()) {
|
|
|
|
|
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
}
|
|
|
|
|
const candidates = new Map<string, Entry>();
|
|
|
|
|
// Constructors own no resources. Prepare every replacement before disposing live plugins.
|
|
|
|
|
for (const item of desired.values()) {
|
|
|
|
|
const current = this.entries.get(item.name);
|
|
|
|
|
if (current && isDeepStrictEqual(current.config,
|
|
|
|
|
resolve_config(current.instance.config_template, (item.config ?? {}) as Record<string, unknown>))) continue;
|
|
|
|
|
try { candidates.set(item.name, await this.prepare(item)); }
|
|
|
|
|
catch (error) {
|
|
|
|
|
if (!current && error instanceof PluginError && error.reason === 'LOAD_FAILED') {
|
|
|
|
|
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
|
|
|
|
|
enabled: false, lifecycle: 'error', states: null});
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
this.check_active();
|
|
|
|
|
for (const name of Array.from(this.entries.keys())) {
|
|
|
|
|
if (!desired.has(name) || candidates.has(name)) await this.remove(name);
|
|
|
|
|
this.check_active();
|
|
|
|
|
}
|
|
|
|
|
for (const candidate of candidates.values()) await this.install(candidate);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
init(plugins: bot_config_plugin<unknown>[]): Promise<void> {
|
|
|
|
|
return this.enqueue(async () => {
|
|
|
|
|
const names = new Set(this.entries.keys());
|
|
|
|
|
for (const item of plugins) {
|
|
|
|
|
validate_item(item);
|
|
|
|
|
if (names.has(item.name)) throw new PluginError('ALREADY_INSTALLED');
|
|
|
|
|
names.add(item.name);
|
|
|
|
|
}
|
|
|
|
|
const installed: string[] = [];
|
|
|
|
|
try {
|
|
|
|
|
for (const item of plugins) {
|
|
|
|
|
let candidate: Entry;
|
|
|
|
|
try { candidate = await this.prepare(item); }
|
|
|
|
|
catch (error) {
|
|
|
|
|
if (error instanceof PluginError && error.reason === 'LOAD_FAILED') {
|
|
|
|
|
this.publish({name: item.name, path: item.path, config: (item.config ?? {}) as Record<string, unknown>,
|
|
|
|
|
enabled: false, lifecycle: 'error', states: null});
|
|
|
|
|
}
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
await this.install(candidate);
|
|
|
|
|
installed.push(item.name);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
const errors = [error];
|
|
|
|
|
for (const name of installed.reverse()) {
|
|
|
|
|
try { await this.remove(name); } catch (cleanup) { errors.push(cleanup); }
|
|
|
|
|
}
|
|
|
|
|
if (errors.length > 1) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get_plugin(name: string): PluginSnapshot | undefined {
|
|
|
|
|
const record = this.records.get(name);
|
|
|
|
|
return record && structuredClone(record);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get_all_plugins(): PluginSnapshot[] {
|
|
|
|
|
return Array.from(this.records.values(), record => structuredClone(record));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inject(item: bot_config_plugin<unknown>): Promise<void> { return this.init([item]); }
|
|
|
|
|
|
|
|
|
|
re_inject(item: bot_config_plugin<unknown>): Promise<void> {
|
|
|
|
|
return this.enqueue(async () => {
|
|
|
|
|
const candidate = await this.prepare(item);
|
|
|
|
|
await this.remove(item.name);
|
|
|
|
|
await this.install(candidate);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update_config(name: string, config: Record<string, unknown>): Promise<void> {
|
|
|
|
|
return this.enqueue(async () => {
|
|
|
|
|
if (!valid_name(name) || !valid_plugin_config(config)) throw new PluginError('INVALID_CONFIG');
|
|
|
|
|
const previous = this.entries.get(name);
|
|
|
|
|
if (!previous || !this.records.get(name)?.enabled) throw new PluginError('NOT_INSTALLED');
|
|
|
|
|
const candidate = await this.prepare({name, path: previous.path, config});
|
|
|
|
|
this.check_active();
|
|
|
|
|
await this.remove(name);
|
|
|
|
|
await this.install(candidate);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async remove(name: string) {
|
|
|
|
|
const entry = this.entries.get(name);
|
|
|
|
|
if (!entry) return;
|
|
|
|
|
if (entry.quarantined) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
this.transition(entry, 'disabling');
|
|
|
|
|
let failed = false;
|
|
|
|
|
try { await entry.instance.dispose(); }
|
|
|
|
|
catch { failed = true; }
|
|
|
|
|
finally {
|
|
|
|
|
try { entry.unsubscribe?.(); } catch { failed = true; }
|
|
|
|
|
if (failed) entry.quarantined = true;
|
|
|
|
|
else this.entries.delete(name);
|
|
|
|
|
this.transition(entry, failed ? 'error' : 'disabled');
|
|
|
|
|
}
|
|
|
|
|
if (failed) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uninject(name: string): Promise<void> { return this.enqueue(() => this.remove(name)); }
|
|
|
|
|
|
|
|
|
|
dispose(): Promise<void> {
|
|
|
|
|
if (this.dispose_task) return this.dispose_task;
|
|
|
|
|
this.disposed = true;
|
|
|
|
|
this.dispose_task = this.tail.then(async () => {
|
|
|
|
|
const errors: unknown[] = [];
|
|
|
|
|
for (const name of Array.from(this.entries.keys()).reverse()) {
|
|
|
|
|
try { await this.remove(name); } catch (error) { errors.push(error); }
|
|
|
|
|
}
|
|
|
|
|
this.listeners.clear();
|
|
|
|
|
if (errors.length) throw new PluginError('DISPOSE_FAILED');
|
|
|
|
|
});
|
|
|
|
|
return this.dispose_task;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function create_plugin_manager(context: PluginContext, plugins: bot_config_plugin<unknown>[]): Promise<PluginManager> {
|
|
|
|
|
const manager = new PluginManager(context);
|
|
|
|
|
try { await manager.init(plugins); return manager; }
|
|
|
|
|
catch (error) {
|
|
|
|
|
try { await manager.dispose(); } catch { throw new PluginError('DISPOSE_FAILED'); }
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function get_plugin_list(): Promise<{path: string; name: string; path_config: base_define; plugin_config_template: plugin}[]> {
|
|
|
|
|
const result: Awaited<ReturnType<typeof get_plugin_list>> = [];
|
|
|
|
|
const directories = [''];
|
|
|
|
|
const names = new Set<string>();
|
|
|
|
|
for (const path of directories) {
|
|
|
|
|
const directory = new URL(path ? `./${path}/` : './', import.meta.url);
|
|
|
|
|
const entries = (await readdir(directory, {withFileTypes: true})).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 || !entries.some(entry => entry.isFile() && entry.name === 'define.ts')) continue;
|
|
|
|
|
const {define} = await import_module(new URL('define.ts', directory)) as {define: base_define};
|
|
|
|
|
if (define?.path !== path || !valid_i18n(define.i18n)) throw new Error(`Invalid plugin category: ${path}`);
|
|
|
|
|
for (const entry of entries) {
|
|
|
|
|
if (!entry.isFile() || entry.name === 'define.ts' || !/^[a-zA-Z0-9_-]+\.ts$/.test(entry.name)) continue;
|
|
|
|
|
const module = await import_module(new URL(entry.name, directory));
|
|
|
|
|
if (!Object.hasOwn(module, 'init')) continue;
|
|
|
|
|
const name = entry.name.slice(0, -3);
|
|
|
|
|
const instance = instantiate(module, name);
|
|
|
|
|
if (names.has(name)) throw new Error(`Duplicate plugin: ${name}`);
|
|
|
|
|
names.add(name);
|
|
|
|
|
result.push({path, name, path_config: structuredClone(define), plugin_config_template: structuredClone(instance.config_template)});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return result.sort((a, b) => a.path.localeCompare(b.path) || a.name.localeCompare(b.name));
|
|
|
|
|
}
|