/* eslint-disable @typescript-eslint/no-non-null-assertion */ import { Plugin, EventListener, Configurable, IPluginManifest, IPlugin, DependencyLoad, } from '@squeebot/core/lib/plugin'; import { IChannel } from '@squeebot/core/lib/channel'; import { IRepoPluginDef, IRepository, } from '@squeebot/core/lib/plugin/repository'; import { ISqueebotCore, logger } from '@squeebot/core/lib/core'; import path from 'path'; import fs from 'fs/promises'; interface ControlCommand { execute: (p: ControlPlugin, ...args: any[]) => Promise; name: string; plugin: string; } interface SocketMessage { [x: string]: unknown; command?: string; id?: string; status?: string; arguments?: unknown[]; list?: unknown[]; data?: unknown; } type ReplyFn = (message: SocketMessage) => void; let controlCommands: ControlCommand[] = [ { name: 'loadPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo('core', 'pluginLoad', plugin); }, }, { name: 'unloadPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } p.stream.emitTo(plugin, 'pluginUnload', plugin); }, }, { name: 'listActivePlugins', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.getLoaded().map((x: IPlugin) => x.manifest); }, }, { name: 'listInstalledPlugins', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.pluginManager.availablePlugins; }, }, { name: 'installPlugin', plugin: 'control', execute: async ( p: ControlPlugin, plugin: string ): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installPlugin(plugin); }, }, { name: 'uninstallPlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallPlugin(plugin); }, }, { name: 'enablePlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } if (!p.core!.pluginManager.getAvailableByName(plugin)) { throw new Error('No such plugin is available.'); } if (!p.core!.config.config.enabled) { p.core!.config.config.enabled = [plugin]; } else if (p.core!.config.config.enabled.indexOf(plugin) === -1) { p.core!.config.config.enabled.push(plugin); } return p.core!.config.save(); }, }, { name: 'disablePlugin', plugin: 'control', execute: async (p: ControlPlugin, plugin: string): Promise => { if (!plugin) { throw new Error('This function takes 1 argument.'); } if (!p.core!.pluginManager.getAvailableByName(plugin)) { throw new Error('No such plugin is available.'); } if (!p.core!.config.config.enabled) { return; } const indx = p.core!.config.config.enabled.indexOf(plugin); if (indx > -1) { p.core!.config.config.enabled.splice(indx, 1); } return p.core!.config.save(); }, }, { name: 'installRepository', plugin: 'control', execute: async (p: ControlPlugin, url: string): Promise => { if (!url) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.installRepository(url); }, }, { name: 'uninstallRepository', plugin: 'control', execute: async (p: ControlPlugin, repo: string): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } return p.core!.repositoryManager.uninstallRepository(repo); }, }, { name: 'listRepositoryPlugins', plugin: 'control', execute: async ( p: ControlPlugin, repo: string ): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } const repoData = p.core!.repositoryManager.getRepoByName(repo); if (!repoData) { throw new Error('No such repository found.'); } return repoData.plugins; }, }, { name: 'listRepositories', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.repositoryManager.getAll(); }, }, { name: 'updateRepository', plugin: 'control', execute: async ( p: ControlPlugin, repo: string ): Promise => { if (!repo) { throw new Error('This function takes 1 argument.'); } const repoData = p.core!.repositoryManager.getRepoByName(repo); if (!repoData) { throw new Error('No such repository found.'); } return p.core!.repositoryManager.checkForUpdates(repoData); }, }, { name: 'newChannel', plugin: 'control', execute: async ( p: ControlPlugin, name: string, plugins?: string[] ): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } if (p.core!.channelManager.getChannelByName(name)) { throw new Error('A channel by that name already exists!'); } p.core!.channelManager.addChannel({ name, plugins: plugins || [], enabled: true, }); }, }, { name: 'removeChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } p.core!.channelManager.removeChannel(chan); }, }, { name: 'enableChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } chan.enabled = true; p.core!.config.config.channels = p.core!.channelManager.getAll(); await p.core!.config.save(); }, }, { name: 'disableChannel', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } chan.enabled = false; p.core!.config.config.channels = p.core!.channelManager.getAll(); await p.core!.config.save(); }, }, { name: 'addChannelPlugin', plugin: 'control', execute: async ( p: ControlPlugin, name: string, plugins: string | string[] ): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } if (!Array.isArray(plugins)) { plugins = [plugins]; } for (const plugin of plugins) { if (chan.plugins.indexOf(plugin) === -1) { chan.plugins.push(plugin); } } }, }, { name: 'removeChannelPlugin', plugin: 'control', execute: async ( p: ControlPlugin, name: string, plugins: string | string[] ): Promise => { if (!name || !plugins) { throw new Error('This function takes 2 arguments.'); } const chan = p.core!.channelManager.getChannelByName(name); if (!chan) { throw new Error('A channel by that name does not exists!'); } if (!Array.isArray(plugins)) { plugins = [plugins]; } for (const plugin of plugins) { const idx = chan.plugins.indexOf(plugin); if (idx !== -1) { chan.plugins.splice(idx, 1); } } }, }, { name: 'listChannels', plugin: 'control', execute: async (p: ControlPlugin): Promise => { return p.core!.channelManager.getAll(); }, }, { name: 'getPluginConfig', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } return plugin.config.config; }, }, { name: 'getPluginConfigValue', plugin: 'control', execute: async ( p: ControlPlugin, name: string, key: string ): Promise => { if (!name || !key) { throw new Error('This function takes 2 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } return plugin.config.get(key); }, }, { name: 'getPluginConfigSchema', plugin: 'control', execute: async (p: ControlPlugin, name: string): Promise => { if (!name) { throw new Error('This function takes 1 argument.'); } return p.getPluginSchema(name); }, }, { name: 'setPluginConfig', plugin: 'control', execute: async ( p: ControlPlugin, name: string, config: any ): Promise => { if (!name || !config) { throw new Error('This function takes 2 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } plugin.config.config = config; return plugin.config.save(); }, }, { name: 'setPluginConfigValue', plugin: 'control', execute: async ( p: ControlPlugin, name: string, key: string, value: string ): Promise => { if (!name || !key) { throw new Error('This function takes 3 arguments.'); } const plugin = p.core!.pluginManager.getLoadedByName(name); if (!plugin) { throw new Error('No such plugin is currently running!'); } plugin.config.set(key, value); return plugin.config.save(); }, }, ]; @Configurable({ authorizedIPs: [], tls: { enabled: false, key: '', cert: '', rejectUnauthorized: false, requestCert: false, }, bind: null, }) class ControlPlugin extends Plugin { public core: ISqueebotCore | null = null; public plugins = new Map(); public initialize(): void { this.addEventListener('core', (core: ISqueebotCore) => (this.core = core)); this.emitTo('core', 'request-core', this.name); this.getPluginSchema(this.name).catch(() => logger.error( '[control] How embarrasing! control could not load it\'s own schema!' ) ); } /** * Load a plugin schema from it's schema file, if it exists. * @param name Plugin name * @returns Plugin schema */ public async loadPluginSchema(name: string): Promise { if (!this.core) { throw new Error('control could not access the core.'); } const { pluginsPath } = this.core.environment; const schemaPath = path.join(pluginsPath, name, 'schema.json'); let schema: any; try { const fileRead = await fs.readFile(schemaPath, { encoding: 'utf8' }); schema = JSON.parse(fileRead); } catch (e: any) { throw new Error( 'No schema file found, it is not accessible or is not valid JSON.' ); } if (!schema.type) { throw new Error( 'Schema does not specify what type of object it is referencing.' ); } this.plugins.set(name, schema); return schema; } /** * Register a plugin's configuration schema directly instead of reading from file. * @param name Plugin name * @param confspec Static schema */ public registerPluginConfigSchema(name: string, confspec?: unknown): void { this.plugins.set(name, confspec); } /** * Load a plugin's configuration schema from memory or from file. * @param name Plugin name * @returns Schema * @throws Error if schema is not found or is invalid */ public async getPluginSchema(name: string): Promise { if (this.plugins.has(name)) { return this.plugins.get(name); } return this.loadPluginSchema(name); } /** * Execute a registered control command. * @param command Control command * @param args Control command arguments * @returns Control command response */ public async executeControlCommand( command: string, args: string[] ): Promise { if (!this.core) { throw new Error('The control plugin cannot control the bot right now.'); } const cmdobj = controlCommands.find((k) => k.name === command); if (!cmdobj || !cmdobj.execute) { throw new Error('No such command'); } return cmdobj.execute.call(this, this, ...args); } /** * Register a new custom control command. * @param obj ControlCommand object */ public registerControlCommand(obj: ControlCommand): void { if (!obj.execute || !obj.name || !obj.plugin) { throw new Error('Invalid command object.'); } const exists = controlCommands.find((k) => k.name === obj.name); if (exists) { throw new Error('Control commands should not be overwritten.'); } controlCommands.push(obj); logger.log('[%s] registered control command', this.name, obj.name); } public listControlCommands(): string[] { return controlCommands.map((command) => command.name); } @EventListener('pluginUnload') public unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { logger.debug('[%s]', this.name, 'shutting down..'); this.plugins.clear(); this.emit('pluginUnloaded', this); } } @EventListener('pluginUnloaded') public unloadedEventHandler(plugin: string | Plugin): void { if (typeof plugin !== 'string') { plugin = plugin.manifest.name; } this.plugins.delete(plugin); controlCommands = controlCommands.filter((k) => k.plugin !== plugin); } @DependencyLoad('socket') public socketDepLoaded(socketAPI: any) { const config = this.config.config; socketAPI .createServer({ name: 'control', plugin: this.name, ...config, }) .then((server: any) => { server.on('message', (...args: [SocketMessage, never, ReplyFn]) => this.handleClientLine(...args) ); }) .catch((err: Error) => logger.error('[%s] Failed to initialize: ', this.name, err.message) ); } private handleClientLine( message: SocketMessage, sender: never, reply: ReplyFn ): void { if (!message.command) return; this.executeControlCommand( message.command, (message.arguments as string[]) || [] ).then( (cmdData) => { try { const response: SocketMessage = { status: 'OK', command: message.command, id: message.id }; if (cmdData != null) { if (Array.isArray(cmdData)) { response.list = cmdData; } else { response.data = cmdData; } } reply(response); } catch (error) { reply({ status: 'ERROR', arguments: [(error as Error).message], id: message.id }); } }, (error) => reply({ status: 'ERROR', arguments: [(error as Error).message], id: message.id }) ); } } module.exports = ControlPlugin;