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'; import { IChannel } from '@squeebot/core/lib/channel'; declare type Matcher = (msg: IMessage) => any; 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[]; tempargv?: any[]; execute(msg: IMessage, command: CommandSpec, prefix: string, ...args: any[]): Promise; } interface RateLimit { rooms: string[]; perSecond: number; cooldown: number; } interface Rate { lastMessage: number; messages: number; } const rates: {[key: string]: Rate} = {}; @Configurable({ prefix: { '*': '!' }, keywords: ['squeebot'], rateLimits: [], channelMatching: false, }) class SqueebotCommandsAPIPlugin extends Plugin { private commands: CommandSpec[] = []; private permissions: any = null; public getRateLimit(room: string): RateLimit | null { for (const rm of this.config.get('rateLimits', [])) { if (rm.rooms && (rm.rooms.indexOf(room) !== -1 || rm.rooms.indexOf('*') !== -1)) { return rm; } } return null; } public doLimiting(room: string, sender: string): boolean { const rl = this.getRateLimit(room); if (!rl) { return false; } // Hasn't been limited yet if (!rates[sender]) { rates[sender] = { lastMessage: Date.now(), messages: 1, }; return false; } const r = rates[sender]; if (r.lastMessage > Date.now() - rl.cooldown) { if (r.messages >= rl.perSecond) { // Rate limited return true; } r.lastMessage = Date.now(); r.messages++; return false; } r.lastMessage = Date.now(); r.messages = 1; return false; } 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, plugins: string[]): Promise { const text = msg.text; 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 (plugins.length && plugins.indexOf(spec.plugin) === -1) { continue; } 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); // Rate limit check if (permitted.length && this.config.get('rateLimits', []).length && this.doLimiting(msg.fullRoomID!, msg.fullSenderID!)) { logger.warn('[%s] User %s rate limited', this.name, msg.fullSenderID); return; } // Start executing for (const spec of permitted) { // Help command needs access to plugin list in the channel // Very dirty if (spec.name === 'help') { spec.tempargv = plugins; } const success = await spec.execute(msg, spec, prefix, ...separate.slice(1)); if (success) { break; } } // Done } public async handleKeywords(msg: IMessage, keyword: string, plugins: string[]): Promise { const text = msg.text.toLowerCase(); // Only pass command specs which have `match` and match rooms let matching = []; for (const spec of this.commands) { if (!spec.match) { continue; } if (plugins.length && plugins.indexOf(spec.plugin) === -1) { continue; } if (typeof spec.match === 'function') { try { const match = spec.match(msg); if (match == null) { continue; } spec.tempargv = match; } catch (e) {} } else { const rgx = text.match(spec.match); if (rgx == null) { continue; } spec.tempargv = rgx.slice(1); } 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); // Rate limit check if (permitted.length && this.config.get('rateLimits', []).length && this.doLimiting(msg.fullRoomID!, msg.fullSenderID!)) { logger.warn('[%s] User %s rate limited', this.name, msg.fullSenderID); return; } // Start executing for (const spec of permitted) { const success = await spec.execute(msg, spec, keyword, ...(spec.tempargv ? spec.tempargv : [])); if (success) { break; } } // Done } @EventListener('message') public digest(msg: IMessage, chan: IChannel): void { if (msg.type !== EMessageType.message) { return; } let allowedPlugins: string[] = []; if (chan && this.config.get('channelMatching', false) === true) { allowedPlugins = chan.plugins; } 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.toLowerCase().match(kw) != null) { this.handleKeywords(msg, kw, allowedPlugins).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, allowedPlugins).catch(e => logger.error('[%s] Command handler threw an error:', this.name, e.stack)); } public registerCommand(spec: CommandSpec | CommandSpec[], bulk = false): 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, true)) { success = false; } } return success; } else if (!bulk) { 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, plugins: string[]): void { // Iteration 1: Resolve commands by name and by aliases const withAliases = []; for (const spec of this.commands) { if (plugins.length && plugins.indexOf(spec.plugin) === -1) { continue; } 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); } // Iteration 2: Match rooms for message let matching = this.roomMatcher(msg, withAliases); // Iteration 3: Match permissions for user matching = this.permissionMatcher(msg, 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, spec.tempargv as string[]); return true; } }); } @DependencyLoad('permissions') permissionsAdded(plugin: Plugin): void { this.permissions = plugin; } @DependencyUnload('permissions') permissionsRemoved(): void { this.permissions = null; } @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)); } } @EventListener('pluginUnloaded') unloadedPlugin(plugin: string | Plugin): void { this.unregisterPlugin((typeof plugin === 'string' ? plugin : plugin.manifest.name)); } } module.exports = SqueebotCommandsAPIPlugin;