import 'reflect-metadata'; import * as path from 'path'; import { IEnvironment, Service } from '../types'; import { IPlugin, IPluginManifest } from './plugin'; import { PluginConfiguration } from '../types'; import { PluginConfigurator } from './config'; import { ScopedEventEmitter, requireNoCache } from '../util'; import { NPMExecutor } from '../npm/executor'; import { logger } from '../core/logger'; /** * Plugin management and execution system */ export class PluginManager { private plugins: Map = new Map(); private configs: PluginConfigurator = new PluginConfigurator(this.environment); private restartQueue: Map = new Map(); private stopping = false; constructor( public availablePlugins: IPluginManifest[], private stream: ScopedEventEmitter, private environment: IEnvironment, private npm: NPMExecutor ) { this.addEvents(); } /** * Get a plugin manifest by plugin name, if it is available. * @param name Plugin name * @returns Plugin manifest */ public getAvailableByName(name: string): IPluginManifest | undefined { return this.availablePlugins.find(p => p.name === name); } /** * Get a loaded plugin by name. * @param name Plugin name * @returns Plugin instance */ public getLoadedByName(name: string): IPlugin | undefined { if (this.plugins.has(name)) { return this.plugins.get(name) as IPlugin; } return; } /** * Get a list of all loaded plugins. * @returns List of loaded plugins */ public getLoaded(): IPlugin[] { return Array.from(this.plugins.values()); } /** * Add plugin manifests as available (ready to load). * * Also used to replace existing manifest after installing a plugin update. * @param manifest Plugin manifest or list of * @returns true on success */ public addAvailable(manifest: IPluginManifest | IPluginManifest[]): boolean { // Automatically add arrays of manifests if (Array.isArray(manifest)) { let returnValue = true; for (const mf of manifest) { if (!this.addAvailable(mf)) { returnValue = false; } } return returnValue; } // Replace the manifest if (this.getAvailableByName(manifest.name)) { this.removeAvailable(manifest, false); this.availablePlugins.push(manifest); return true; } if (!(/^[a-zA-Z0-9_-]+$/.test(manifest.name))) { throw new Error('Illegal name for a plugin!'); } this.availablePlugins.push(manifest); return true; } /** * Remove a plugin from available list, usually called after uninstalling a plugin. * @param plugin Plugin name, plugin manifest or list of * @param unload If they're loaded, unload them * @returns true on success */ public removeAvailable( plugin: IPluginManifest | IPluginManifest[] | string | string[], unload = true ): boolean { let returnValue = false; if (Array.isArray(plugin)) { returnValue = true; for (const mf of plugin) { if (!this.removeAvailable(mf)) { returnValue = false; } } return returnValue; } // By name if (typeof plugin === 'string') { const p = this.getAvailableByName(plugin); if (!p) { return false; } plugin = p; } const result: IPluginManifest[] = []; for (const mf of this.availablePlugins) { if (mf.name === plugin.name) { returnValue = true; if (unload) { this.stream.emit('pluginUnload', mf); } continue; } result.push(mf); } this.availablePlugins = result; return returnValue; } /** * Load a plugin into memory and start executing it. * @param plugin Plugin manifest * @returns Plugin instance */ public async load(plugin: IPluginManifest): Promise { // Ignore loading when we're shutting down if (this.stopping) { throw new Error('Squeebot is shutting down'); } // Don't load plugins twice const ready = this.getLoadedByName(plugin.name); if (ready) { return ready; } // Dependencies required to load const requires = []; // Dependencies available const available = []; logger.debug('Loading plugin', plugin.name); // Check dependencies for (let dep of plugin.dependencies) { let optional = false; if (dep.indexOf('?') !== -1) { dep = dep.replace('?', ''); optional = true; } if (dep === plugin.name) { throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`); } const existing = this.getLoadedByName(dep); if (existing) { available.push(existing.manifest); continue; } const isLoaded = this.getAvailableByName(dep); if (!isLoaded) { if (optional) { continue; } throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`); } requires.push(isLoaded); } // Load dependencies logger.debug('Loading plugin %s dependencies', plugin.name); for (const manifest of requires) { try { await this.load(manifest); available.push(manifest); } catch (e: any) { logger.error(e.stack); throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`); } } // Load npm modules logger.debug('Loading plugin %s npm modules', plugin.name); for (const depm of plugin.npmDependencies) { try { await this.npm.installPackage(depm); } catch (e: any) { logger.error(e.stack); throw new Error(`Plugin dependency "${depm}" installation failed for "${plugin.name}"`); } } // Load the configuration const config: PluginConfiguration = await this.configs.loadConfig(plugin); // Ensure plugin has a full path if (!plugin.fullPath) { plugin.fullPath = path.join(this.environment.pluginsPath, plugin.name); } // Load the module logger.debug('Loading plugin %s module', plugin.name); const PluginModule = requireNoCache(path.resolve(plugin.fullPath, plugin.main)) as any; if (!PluginModule) { throw new Error(`Plugin "${plugin.name}" loading failed.`); } // Find default configuration, if it's configured, load the configuration if (Reflect.hasOwnMetadata('sb:defconf', PluginModule) && config) { config.setDefaults(Reflect.getOwnMetadata('sb:defconf', PluginModule)); await config.load(); } // Construct an instance of the module logger.debug('Instancing plugin %s', plugin.name); const loaded = new PluginModule(plugin, this.stream, config); try { // Give the plugin a service if (Reflect.hasOwnMetadata('sb:service', PluginModule)) { loaded.service = new Service(Reflect.getOwnMetadata('sb:service', PluginModule)); } // Call the initializer if (loaded.initialize) { loaded.initialize.call(loaded); } // Call methods that are supposed to be executed automatically on load. const autorunFunctions: Array = Reflect.getOwnMetadata('sb:autorunners', PluginModule.prototype); if (autorunFunctions?.length) { for (const autoFunction of autorunFunctions) { if (!loaded[autoFunction]) continue; if (autoFunction === 'initialize') continue; loaded[autoFunction].call(loaded); } } } catch (e: any) { logger.error(e.stack); throw new Error(`Plugin "${plugin.name}" initialization failed.`); } this.plugins.set(plugin.name, loaded); this.stream.emit('pluginLoaded', loaded); // Inform the new plugin that it's dependencies are available for (const depn of available) { this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name)); } return loaded; } /** * Restart a loaded plugin. * @param mf Plugin instance, plugin manifest or plugin name * @param wait Waits for the plugin to be available before resolving */ public async restart(mf: IPluginManifest | IPlugin | string, wait = false): Promise { let manifest; if (typeof mf === 'string') { manifest = this.getAvailableByName(mf); } else if ('manifest' in mf) { manifest = mf.manifest; } else { manifest = mf; } if (!manifest) { throw new Error('Plugin not found'); } const pluginName = manifest.name; if (!this.getLoadedByName(pluginName)) { this.load(manifest); return; } this.restartQueue.set(pluginName, manifest); this.stream.emitTo(pluginName, 'pluginUnload', pluginName); if (wait) { return new Promise((resolve, reject) => { let retries = 0; const checkInterval = setInterval(() => { // Plugin has been loaded, resolve the promise if (this.getLoadedByName(pluginName)) { clearInterval(checkInterval); return resolve(); } // Increment retry count and wait for next iteration retries++; if (retries >= 20) { // Give up after 10 seconds clearInterval(checkInterval); reject(new Error('Could not determine loaded status within a reasonable time frame.')); } }, 500); }); } } /** * Listen for plugin status events. */ private addEvents(): void { this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => { if (typeof mf === 'string') { const manifest = this.getAvailableByName(mf); if (manifest) { return this.load(manifest).catch((e) => logger.error(e.stack)); } } }); this.stream.on('core', 'pluginUnloaded', (mf: IPlugin | string) => { if (typeof mf !== 'string') { if (mf.manifest && mf.service != null) { mf.service.die(); } mf = mf.manifest.name; } else { const st = this.getLoadedByName(mf); if (st && st.manifest && st.service != null) { st.service.die(); } } logger.debug('%s has unloaded.', mf); // Delete plugin from the list of loaded plugins this.plugins.delete(mf); // Remove all listeners created by the plugin this.stream.removeName(mf); // Restart, if applicable if (this.restartQueue.has(mf) && !this.stopping) { const manifest = this.restartQueue.get(mf) as IPluginManifest; this.restartQueue.delete(mf); this.load(manifest).catch(e => console.error(e)); } }); this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => { const pluginName = (typeof mf === 'string' ? mf : mf.manifest.name); logger.debug('Killing plugin %s', pluginName); this.stream.emitTo(pluginName, 'pluginUnload', pluginName); this.stream.emit('pluginUnloaded', mf); }); this.stream.on('core', 'shutdown', (state: number) => { // When the shutdown is initiated, this state will be zero. // We will be re-emitting this event with a higher state when // all the plugins have finished shutting down. if (state !== 0) { return; } // Prevent loading of new plugins this.stopping = true; logger.debug('Shutdown has been received by plugin manager'); // Shutting down all the plugins for (const [name, plugin] of this.plugins) { this.stream.emitTo(name, 'pluginUnload', name); } // Every second check for plugins let testCount = 0; const testInterval = setInterval(() => { // There's still plugins loaded.. if (this.plugins.size > 0 && testCount < 5) { testCount++; return; } // Shut down when there are no more plugins active or // after 5 seconds we just force shutdown clearInterval(testInterval); this.stream.emitTo('core', 'shutdown', 1); }, 1000); }); } }