import { Plugin, EventListener, Configurable, DependencyLoad, DependencyUnload } from '@squeebot/core/lib/plugin'; import { EMessageType, IMessage, MessageResolver } 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, msr: MessageResolver, command: CommandSpec, prefix: string, ...args: any[]): Promise; } interface RateLimit { rooms: string[]; rate: number; cooldown: number; } interface Rate { lastMessage: number; messages: number; } const rates: {[key: string]: Rate} = {}; @Configurable({ prefix: { '*': '!' }, 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.rate) { // Rate limited return true; } r.lastMessage = Date.now(); r.messages++; return false; } r.lastMessage = Date.now(); r.messages = 1; return false; } public setRateLimit(room: string, rate: number, cooldown: number): void { const basis = this.config.get('rateLimits', []) as RateLimit[]; const found = basis.find((item) => item.rooms.includes(room)); const resave = () => { this.config.set('rateLimits', basis); this.config.save(); }; const addNew = () => { const newRate: RateLimit = { rate, cooldown, rooms: [room] }; basis.push(newRate); resave(); }; if (found) { if (rate === 0 || (rate !== found.rate || cooldown !== found.cooldown)) { if (rate === 0 || found.rooms.length > 1) { found.rooms.splice(found.rooms.indexOf(room), 1); } if (rate === 0) { if (found.rooms.length === 0) { basis.splice(basis.indexOf(found), 1); } resave(); return; } addNew(); return; } if (found.rooms.length > 1) { addNew(); return; } found.rate = rate; found.cooldown = cooldown; resave(); return; } addNew(); } 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[], msr: MessageResolver ): 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 && msg.fullRoomID && msg.fullSenderID && 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 argv: any[] = this.splitArguments(text).slice(1); if (spec.plugin === this.name) { argv.unshift(plugins); } const success = await spec.execute(msg, msr, spec, prefix, ...argv); if (success) { break; } } // Done } @EventListener('message') public digest(msg: IMessage, chan: IChannel, msr: MessageResolver): void { if (msg.type !== EMessageType.message) { return; } let allowedPlugins: string[] = []; if (chan && this.config.get('channelMatching', false) === true) { allowedPlugins = chan.plugins; } const text = msg.text; const prefixes = this.config.config.prefix; 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, msr).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 withAliases(msg: IMessage, plugins: string[]): CommandSpec[] { 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); } return withAliases; } private aliasesCommand(msg: IMessage, prefix: string, args: any[]): void { // First argument could be a passed plugin array within this plugin let plugins: string[] = []; let cmdarg = 0; if (args && args.length && Array.isArray(args[0])) { plugins = args[0]; cmdarg += 1; } // Iteration 1: Resolve commands (with aliases) const withAliases = this.withAliases(msg, plugins); // Iteration 2: Match rooms for message let matching = this.roomMatcher(msg, withAliases); // Iteration 3: Match permissions for user matching = this.permissionMatcher(msg, matching); const b = (t: string) => msg.source.format.format('bold', t); if (!args[cmdarg]) { msg.resolve('A command name is required.'); return; } let found: CommandSpec | null = null; for (const spec of matching) { if (spec.name === args[cmdarg]) { found = spec; break; } } if (!found) { msg.resolve('aliases: No such command "%s"!', args[cmdarg]); return; } if (!found.aliases || found.aliases.length === 0) { msg.resolve('%s - No aliases found.', b(prefix + found.name)); return; } let list = found.aliases; let aliasText = ''; if (found.alias) { aliasText = b(`[alias of ${found.alias}]`); list = list.filter((x) => x !== found?.name); } msg.resolve(b('Aliases of %s:'), prefix + found.name, list.join(', '), aliasText); } private helpCommand(msg: IMessage, prefix: string, args: any[]): void { // First argument could be a passed plugin array within this plugin let plugins: string[] = []; let cmdarg = 0; if (args && args.length && Array.isArray(args[0])) { plugins = args[0]; cmdarg += 1; } // Iteration 1: Resolve commands (with aliases) const withAliases = this.withAliases(msg, plugins); // Iteration 2: Match rooms for message let matching = this.roomMatcher(msg, withAliases); // Iteration 3: Match permissions for user matching = this.permissionMatcher(msg, matching); const b = (t: string) => msg.source.format.format('bold', t); if (args[cmdarg]) { let found: CommandSpec | null = null; for (const spec of matching) { if (spec.name === args[cmdarg]) { found = spec; break; } } if (!found) { msg.resolve('help: No such command "%s"!', args[cmdarg]); 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(', ') ); } private splitArguments(str: string): string[] { const strArray = str.split(''); const strARGS: string[] = []; let strARGC = 0; let strChar = ''; let isString = false; let escape = false; let escaped = false; function addToArgs(append: string): void { if (!strARGS[strARGC]) { strARGS[strARGC] = ''; } strARGS[strARGC] += append; } for (strChar in strArray) { if (escaped) { escaped = false; } if (escape) { escape = false; escaped = true; } switch (strArray[strChar]) { case '\\': if (!escaped) { escape = true; } else { addToArgs('\\'); } break; case '"': if (!escaped) { if (!isString) { isString = true; } else { isString = false; } } else { addToArgs('"'); } break; case ' ': if (!isString) { strARGC++; } else if (isString) { if (escaped) { addToArgs('\\'); } addToArgs(' '); } break; default: if (escaped) { addToArgs('\\'); } addToArgs(strArray[strChar]); } } return strARGS; } initialize(): void { this.registerCommand({ plugin: this.name, name: 'help', aliases: ['commands'], usage: '[]', description: 'Show command usage or list all available commands', execute: async ( msg: IMessage, msr: MessageResolver, spec: CommandSpec, prefix: string, ...args: any[] ): Promise => { this.helpCommand(msg, prefix, args); return true; } }); this.registerCommand({ plugin: this.name, name: 'aliases', aliases: ['alias'], usage: '', description: 'Show the list of aliases for command', execute: async ( msg: IMessage, msr: MessageResolver, spec: CommandSpec, prefix: string, ...args: any[] ): Promise => { this.aliasesCommand(msg, prefix, args); return true; } }); this.registerCommand({ plugin: this.name, name: 'ratelimit', usage: ' ', description: 'Set command rate limits in the current room', hidden: true, permissions: ['room.rate-limit'], execute: async ( msg: IMessage, msr: MessageResolver, spec: CommandSpec, prefix: string, ...args: any[] ): Promise => { if (!args.length) { const rl = this.getRateLimit(msg.target?.id as string); if (!rl) { msg.resolve(`There are no rate limits enabled for ${msg.target?.name}.`); return true; } const isGlobal = rl.rooms.includes('*'); msg.resolve( `Active rate limit in ${msg.target?.name}${isGlobal ? ' (global)' : ''}: ${rl.rate} messages in ${rl.cooldown}` ); return true; } const [count, cooldown] = args; try { this.setRateLimit(msg.target?.id as string, parseInt(count, 10), parseInt(cooldown, 10)); } catch (e: any) { msg.resolve('Setting rate limit failed:', e.message); return true; } msg.resolve('Rate limit has been configured.'); 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.emit('pluginUnloaded', this); } } @EventListener('pluginUnloaded') unloadedPlugin(plugin: string | Plugin): void { this.unregisterPlugin((typeof plugin === 'string' ? plugin : plugin.manifest.name)); } } module.exports = SqueebotCommandsAPIPlugin;