chore: initial commit

This commit is contained in:
2026-10-22 17:07:08 +08:00
commit 0acef64f48
200 changed files with 35546 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
export type PluginErrorReason = 'INVALID_CONFIG' | 'ALREADY_INSTALLED' | 'NOT_INSTALLED'
| 'MANAGER_DISPOSED' | 'LOAD_FAILED' | 'INIT_FAILED' | 'DISPOSE_FAILED';
export class PluginError extends Error {
constructor(readonly reason: PluginErrorReason) {
super({
INVALID_CONFIG: 'Invalid plugin config', ALREADY_INSTALLED: 'Duplicate plugin',
NOT_INSTALLED: 'Plugin not installed', MANAGER_DISPOSED: 'Plugin manager disposed',
LOAD_FAILED: 'Plugin load failed', INIT_FAILED: 'Plugin initialization failed',
DISPOSE_FAILED: 'Plugin disposal failed',
}[reason]);
this.name = 'PluginError';
}
}
+346
View File
@@ -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));
}
+80
View File
@@ -0,0 +1,80 @@
import type {bot_status} from "../../../types/bot/instance/bot_runtime";
import {BasePlugin, type PluginContext, type plugin} from "../../../types/bot/plugins/plugin";
const config_template = {
name: 'auto_reconnect' as const,
i18n: [{lang: 'zh-CN', name: '自动重连', description: 'Bot 连接断开后自动重连'}],
version: '1.0.0',
config: [
{
name: 'reconnect_delay' as const,
i18n: [{lang: 'zh-CN', name: '重连延时', description: '重连时的延时时间(单位:毫秒)'}],
required: false,
default: 1000,
type: 'number',
},
{
name: 'reconnect_max_attempts' as const,
i18n: [{lang: 'zh-CN', name: '最大重连次数', description: '最大重连次数,默认 10 次'}],
required: false,
default: 10,
type: 'number',
},
],
} satisfies plugin;
type auto_reconnect_status = {attempts: number};
type auto_reconnect_config = {
[K in typeof config_template['config'][number]['name']]: number;
};
export class init extends BasePlugin<auto_reconnect_config, auto_reconnect_status> {
readonly name = 'auto_reconnect';
readonly config_template = config_template;
private cleanup?: () => void;
constructor() {
super();
this.set_states({attempts: 0});
}
async init(context: PluginContext, config: auto_reconnect_config): Promise<void> {
const delay = Number.isInteger(config.reconnect_delay) && config.reconnect_delay >= 0
&& config.reconnect_delay <= 2_147_483_647 ? config.reconnect_delay : 1000;
const max_attempts = Number.isInteger(config.reconnect_max_attempts) && config.reconnect_max_attempts >= 0
&& config.reconnect_max_attempts < 100 ? config.reconnect_max_attempts : 10;
let uninjected = false;
const lost = () => {
if (uninjected || this.get_status()!.attempts >= max_attempts) return;
void context.schedule_reconnect(delay).catch(error => {
if (context.logger) context.log([context.logger.error(`安排自动重连失败: ${String(error)}`)]);
});
};
const attempt = () => { this.set_states({attempts: this.get_status()!.attempts + 1}); };
const reset = () => {
if (this.get_status()!.attempts !== 0) this.set_states({attempts: 0});
};
const updated = (value: bot_status) => {
if (value === 'online') reset();
};
this.cleanup = () => {
if (uninjected) return;
uninjected = true;
context.cancel_reconnect();
context.off('connection_lost', lost);
context.off('reconnect_attempt', attempt);
context.off('connection_requested', reset);
context.off('status_update', updated);
};
context.on('connection_lost', lost);
context.on('reconnect_attempt', attempt);
context.on('connection_requested', reset);
context.on('status_update', updated);
}
async dispose(): Promise<void> {
this.cleanup?.();
this.cleanup = undefined;
}
}
+12
View File
@@ -0,0 +1,12 @@
import {base_define} from "../../../types/bot/plugins/plugin.ts";
export const define: base_define = {
path: "runtime",
i18n: [
{
lang: "zh-CN",
name: "运行时插件",
description: "Bot 运行时的插件,提供一些基础功能"
}
]
}