import { Plugin, EventListener, Configurable, DependencyLoad, DependencyUnload } from '@squeebot/core/lib/plugin'; import { EMessageType, IMessage, IMessageTarget } from '@squeebot/core/lib/types'; import { fullIDMatcher } from '@squeebot/core/lib/common'; import { logger } from '@squeebot/core/lib/core'; declare type Matcher = (msg: IMessage) => boolean; interface CommandSpec { name: string; plugin: string; description?: string; usage?: string; source?: string | string[] | Matcher; tags?: string[]; aliases?: string[]; alias?: string; match?: string | Matcher; hidden?: boolean; permissions?: string[]; execute(msg: IMessage, command: CommandSpec, prefix: string, ...args: any[]): Promise; } @Configurable({ prefix: { '*': '!' }, keywords: ['squeebot'] }) class SqueebotCommandsAPIPlugin extends Plugin { private commands: CommandSpec[] = []; private permissions: any = null; private roomMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] { const roomMatches = []; for (const spec of specList) { if (spec.source) { // This message can't room match if (!msg.fullRoomID) { continue; } if (typeof spec.source === 'function') { try { if (spec.source(msg)) { roomMatches.push(spec); } } catch (e) {} } else if (typeof spec.source === 'string') { if (fullIDMatcher(msg.fullRoomID, spec.source)) { roomMatches.push(spec); } } else if (Array.isArray(spec.source)) { for (const room of spec.source) { if (fullIDMatcher(msg.fullRoomID, room)) { roomMatches.push(spec); break; } } } continue; } // No source requirement roomMatches.push(spec); } return roomMatches; } public permissionMatcher(msg: IMessage, specList: CommandSpec[]): CommandSpec[] { const permitted = []; for (const spec of specList) { if (!spec.permissions) { permitted.push(spec); continue; } if (!this.permissions) { continue; } if (!this.permissions.userPermitted(msg, spec.permissions)) { continue; } permitted.push(spec); } return permitted; } public async handlePrefix(msg: IMessage, prefix: string): Promise { const text = msg.data.text ? msg.data.text : msg.data; const separate = text.split(' '); if (separate[0].indexOf(prefix) === 0) { separate[0] = separate[0].substring(prefix.length); } // Iteration 1: Resolve commands by name and by aliases const withAliases = []; for (const spec of this.commands) { if (spec.aliases && spec.aliases.indexOf(separate[0]) !== -1) { const copy = Object.assign({}, spec); copy.alias = spec.name; withAliases.push(copy); continue; } if (spec.name !== separate[0]) { continue; } withAliases.push(spec); } // Iteration 2: Match rooms, if needed const roomMatches = this.roomMatcher(msg, withAliases); // Nothing matches room requirements if (!roomMatches.length) { return; } // Iteration 3: Sort the array so that the ones that had room matching come up first const sorted = []; for (const spec of roomMatches) { if (spec.source) { sorted.push(spec); continue; } sorted.push(spec); } // Iteration 4: Match permissions for user const permitted = this.permissionMatcher(msg, sorted); // Start executing for (const spec of permitted) { const success = await spec.execute(msg, spec, prefix, ...separate.slice(1)); if (success) { break; } } // Done } public async handleKeywords(msg: IMessage, keyword: string): Promise { const text = msg.data.text ? msg.data.text : msg.data; // Only pass command specs which have `match` and match rooms let matching = []; for (const spec of this.commands) { if (!spec.match) { continue; } if (typeof spec.match === 'function') { try { if (!spec.match(msg)) { continue; } } catch (e) {} } else { if (!text.match(spec.match)) { continue; } } matching.push(spec); } matching = this.roomMatcher(msg, matching); // Nothing matches room requirements if (!matching.length) { return; } // Iteration 2: Sort the array so that the ones that had room matching come up first const sorted = []; for (const spec of matching) { if (spec.source) { sorted.push(spec); continue; } sorted.push(spec); } // Iteration 3: Match permissions for user const permitted = this.permissionMatcher(msg, sorted); // Start executing for (const spec of permitted) { const success = await spec.execute(msg, spec, keyword); if (success) { break; } } // Done } public digest(msg: IMessage): void { if (msg.type !== EMessageType.message) { return; } const text = msg.data.text ? msg.data.text : msg.data; const prefixes = this.config.config.prefix; const keywords = this.config.config.keywords; // Attempt to match keywords if (!keywords && !keywords.length) { for (const kw of keywords) { if (text.match(kw)) { this.handleKeywords(msg, kw).catch(e => logger.error('[%s] Command handler threw an error:', this.name, e.stack)); return; } } } if (!prefixes) { return; } // Attempt to match prefixes, prefers room-specific ones let prefix = '!'; if (typeof prefixes === 'string') { prefix = prefixes; } else if (typeof prefixes === 'object') { if (msg.fullRoomID) { for (const idtag in prefixes) { if (idtag === '*') { prefix = prefixes[idtag]; continue; } if (fullIDMatcher(msg.fullRoomID, idtag)) { prefix = prefixes[idtag]; break; } } } } if (!prefix || text.indexOf(prefix) !== 0) { return; } this.handlePrefix(msg, prefix).catch(e => logger.error('[%s] Command handler threw an error:', this.name, e.stack)); } public registerCommand(spec: CommandSpec | CommandSpec[]): boolean { if (Array.isArray(spec)) { if (!spec.length) { return false; } logger.log('[%s] Plugin %s registered commands %s', this.name, spec[0].plugin || 'unknown', spec.map(x => x.name).join(', ')); let success = true; for (const sp of spec) { if (!this.registerCommand(sp)) { success = false; } } return success; } else { logger.log('[%s] Plugin %s registered command %s', this.name, spec.plugin, spec.name); } if (!spec.name || !spec.execute || !spec.plugin) { throw new Error('Invalid command specification!'); } for (const cmd of this.commands) { if (cmd.name === spec.name && cmd.plugin === spec.plugin) { return false; } } this.commands.push(spec); return true; } public unregisterPlugin(plugin: string): void { const remaining: CommandSpec[] = []; const removed: CommandSpec[] = []; for (const cmd of this.commands) { if (cmd.plugin !== plugin) { remaining.push(cmd); continue; } removed.push(cmd); } if (removed.length) { logger.log('[%s] Plugin %s unregistered command(s):', this.name, plugin, removed.map(x => x.name).join(', ')); this.commands = remaining; } } private helpCommand(msg: IMessage, prefix: string): void { // Iteration 1: Resolve commands by name and by aliases const withAliases = []; for (const spec of this.commands) { if (spec.aliases && spec.aliases.length) { for (const alias of spec.aliases) { const copy = Object.assign({}, spec); copy.name = alias; copy.alias = spec.name; withAliases.push(copy); } } withAliases.push(spec); } const matching = this.roomMatcher(msg, withAliases); // Iteration 2: Match permissions for user // TODO: permission matching const text = msg.data.text ? msg.data.text : msg.data; const argv = text.toLowerCase().split(' '); const b = (t: string) => { return msg.source.format.format('bold', t); }; if (argv[1]) { let found: CommandSpec | null = null; for (const spec of matching) { if (spec.name === argv[1]) { found = spec; break; } } if (!found) { msg.resolve('help: No such command "%s"!', argv[1]); return; } let aliasText = ''; if (found.alias) { aliasText = b(`[alias of ${found.alias}]`); } if (found.usage) { msg.resolve('%s %s -', b(prefix + found.name), found.usage, found.description || 'No description :(', aliasText); return; } msg.resolve('%s -', b(prefix + found.name), found.description || 'No description :(', aliasText); return; } msg.resolve('All commands start with a "%s" prefix!\n%s', prefix, b(`List of commands in ${msg.target?.name}:`), matching .filter(x => x.alias == null && x.hidden !== true) .map(x => x.name).join(', ') ); } initialize(): void { this.registerCommand({ plugin: this.name, name: 'help', aliases: ['commands'], usage: '[]', description: 'Show command usage or list all commands', execute: async (msg: IMessage, spec: CommandSpec, prefix: string): Promise => { this.helpCommand(msg, prefix); return true; } }); } @DependencyLoad('permissions') permissionsAdded(plugin: Plugin): void { this.permissions = plugin; } @DependencyUnload('permissions') permissionsRemoved(): void { this.permissions = null; } @EventListener('message') messageHandler(msg: IMessage): void { this.digest(msg); } @EventListener('pluginUnload') unloadEventHandler(plugin: string | Plugin): void { if (plugin === this.name || plugin === this) { logger.debug('[%s] shutting down..', this.name); this.config.save().then(() => this.emit('pluginUnloaded', this)); } } } module.exports = SqueebotCommandsAPIPlugin;