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(['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) { 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> { url.searchParams.set('t', `${Date.now()}-${process.hrtime.bigint()}`); return import(url.href); } function instantiate(module: Record, 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(); 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): Record { 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 = {}; 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; 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; unsubscribe?: () => void; quarantined?: boolean}; export class PluginManager { private readonly entries = new Map(); private readonly records = new Map(); private readonly listeners = new Set<(event: PluginSnapshot) => void>(); private tail: Promise = Promise.resolve(); private disposed = false; private dispose_task?: Promise; 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): Promise { 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): Promise { 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)}; } 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[]): Promise { return this.enqueue(async () => { if (!Array.isArray(plugins)) throw new PluginError('INVALID_CONFIG'); const desired = new Map>(); 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(); // 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))) 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, 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[]): Promise { 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, 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): Promise { return this.init([item]); } re_inject(item: bot_config_plugin): Promise { 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): Promise { 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 { return this.enqueue(() => this.remove(name)); } dispose(): Promise { 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[]): Promise { 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> = []; const directories = ['']; const names = new Set(); 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)); }