const escapeRegex = require('escape-string-regexp'); const moment = require('moment'); const { Argument, Observer } = require('../../../interfaces/'); const Constants = { QuotePairs: { '"': '"', //regular double "'": "'", //regular single "‘": "’", //smart single "“": "”" //smart double } }; class CommandHandler extends Observer { constructor(client) { super(client, { name: 'commandHandler', priority: 5, guarded: true }); this.client = client; this.hooks = [ ['message', this.handleMessage.bind(this)] ]; this._startQuotes = Object.keys(Constants.QuotePairs); //Used for the "getQuotes" function. Parses arguments with quotes in it to combine them into one array argument. this._quoteMarks = this._startQuotes + Object.values(Constants.QuotePairs) // ^ .join(''); } async handleMessage(message) { if(!this.client._built || message.webhookID || message.author.bot || message.guild && (!message.guild.available || message.guild.banned)) return undefined; //Don't parse commands if client isn't built (commands aren't loaded), is a webhook, bot, or if the guild isn't available/is banned. if (message.guild) { if (!message.member) await message.guild.members.fetch(message.author.id); //Fetch member if discord.js doesn't fetch them automatically. } const { command, parameters } = await this._getCommand(message); if(!command) return undefined; message.command = command; // const timestamp1 = new Date().getTime(); const response = await this._parseArguments(parameters, command.arguments, message.guild); if(response.error) { return this.handleError(message, { type: 'argument', ...response }); } if(command.keepQuotes) { message.parameters = response.parameters.map(([param, isQuote]) => isQuote ? `"${param}"` : param); } else { message.parameters = response.parameters.map((p) => p[0]); } // const timestamp2 = new Date().getTime(); // this.client.logger.debug(`Client took ${timestamp2-timestamp1}ms to parse arguments.`); message.arguments = response.args; return this.handleCommand(message, response.parameters); } async _parseArguments(parameters = [], args = [], guild = null) { parameters = this._getQuotes(parameters.join(' ')); const { shortArgs, longArgs } = await this._createArguments(args); const regex = new RegExp(`([0-9]*)(${Object.keys(longArgs).sort((a, b) => b.length - a.length) .map((k) => escapeRegex(k)) .join('|')})([0-9]*)`, 'iu'); const findArgument = (word) => { const matches = regex.exec(word); const [one, two, ...chars] = word.split(''); let arg = null, value = null; if(one === '-' && two !== '-') { const name = [two, ...chars].join(''); const shortFlag = shortArgs[name]; if(shortFlag) { arg = shortFlag; arg._flag = true; } } else if(one === '-' && two === '-' || one === '—' && two !== '—') { const name = one === '—' ? [two, ...chars].join('') : chars.join(''); const longFlag = longArgs[name]; if(longFlag) { arg = longFlag; arg._flag = true; } } else if(matches && (matches[1] || matches[3])) { const [,, name] = matches; const number = matches[1] || matches[3]; const argument = longArgs[name]; if(argument && argument.types.includes('VERBAL')) { arg = longArgs[name]; value = number; } } else if(longArgs[word.toLowerCase()]) { const argument = longArgs[word.toLowerCase()]; if(argument && argument.types.includes('VERBAL')) { arg = longArgs[word.toLowerCase()]; } } return { arg, value }; }; const newParameters = [], newArguments = []; const lookBehind = async (argument) => { //Checks previous argument for an integer or float value, "15 points". let response = {}; if(newParameters.length > 0 && ['INTEGER', 'FLOAT'].includes(argument.type)) { const [lastWord] = newParameters[newParameters.length-1]; response = await this._parseArgumentType(argument, lastWord, guild); if(!response.error) { newParameters.pop(); //Deletes latest parameter. newArguments.push(argument); //Adds argument with value of the latest parameter. return { error: false }; } } return { error: true, ...response }; }; const existing = (argument) => { let exists = false; for(const { name } of newArguments) { if(argument && argument.name === name) exists = true; } return exists; }; let error = null, currentArgument = null; for(let i = 0; i < parameters.length; i++) { const [word, isQuote] = parameters[i]; const { arg, value } = findArgument(word); if(currentArgument) { //One of the previous words had an argument, trying to parse the type until error. let response = await this._parseArgumentType(currentArgument, word, guild); if(response.error) { if(response.force) { //Overrides error if min/max (stupid) error = { argument: currentArgument, ...response }; break; } const behind = await lookBehind(currentArgument); //Check for "15 points" (lookbehind the argument) if(!behind.error) { if(!currentArgument.infinite) currentArgument = null; } else { if(currentArgument.required) { if(currentArgument.empty) { if(behind.force) response = behind; //Overrides error if min/max (stupid) error = { argument: currentArgument, ...response }; break; } } else if(currentArgument.empty) { currentArgument.setDefault(); } newArguments.push(currentArgument); currentArgument = null; } if(arg && !existing(arg)) { //TODO: Add a function for this repetitive code if(value) { //Pre-matched value (15pts/pts15) found by regex, won't need a separate word to parse the argument. const response = await this._parseArgumentType(arg, value, guild); if(arg.required) { if(response.error) { error = { argument: arg, ...response }; break; } } newArguments.push(arg); if(arg.infinite) currentArgument = arg; } else { currentArgument = arg; } } else { newParameters.push([word, isQuote]); } } else { newArguments.push(currentArgument); if(!currentArgument.infinite) currentArgument = null; } } else if(arg && !existing(arg)) { //TODO: Add a function for this repetitive code //Trying to find a new argument to parse or add as a parameter. if(value) { //Pre-matched value (15pts/pts15) found by regex, won't need a separate word to parse the argument. const response = await this._parseArgumentType(arg, value, guild); if(arg.required) { if(response.error) { error = { argument: arg, ...response }; break; } } newArguments.push(arg); if(arg.infinite) currentArgument = arg; } else { currentArgument = arg; } } else { newParameters.push([ word, isQuote ]); } } if(error) return error; if(currentArgument) { //Add the last argument awaiting to be parsed (argument was left hanging at the end w/o a value) const behind = await lookBehind(currentArgument); if(!behind.error) { newArguments.push(currentArgument); } else { if(currentArgument.empty) { if(currentArgument.required) { if(behind.force) return { argument: currentArgument, ...behind }; return { index: 'COMMANDHANDLER_TYPE_ERROR', argument: currentArgument, error: true }; } currentArgument.setDefault(); } newArguments.push(currentArgument); } } const object = {}; newArguments.map((a) => object[a.name] = a); //eslint-disable-line no-return-assign return { parameters: newParameters, args: object, error: false }; } async _parseArgumentType(argument, string, guild) { //Parsing argument values to make sure they're correct. const parse = async(argument, string, guild) => { let { error, value } = await this.parseType(argument.type, string, guild); //eslint-disable-line prefer-const if(error) { return { index: 'COMMANDHANDLER_TYPE_ERROR', error }; } if(['INTEGER', 'FLOAT'].includes(argument.type)) { const { min, max } = argument; if(max !== null && value > max) { if(!argument.ignoreInvalid) return { index: `COMMANDHANDLER_NUMBERMAX_ERROR`, args: { min, max }, force: true, error: true }; value = argument.max; } else if(min !== null && value < min) { if(!argument.ignoreInvalid) return { index: `COMMANDHANDLER_NUMBERMIN_ERROR`, args: { min, max }, force: true, error: true }; value = argument.min; } } if(argument.options.length > 0 && !argument.options.includes(value)) return { index: `COMMANDHANDLER_OPTIONS_ERROR`, args: { options: argument.options.map((o) => `\`${o}\``).join(', ') }, error: true }; return { error: false, value }; }; const response = await parse(argument, string, guild); if(response.error) { return response; } const { value } = response; if(value) { if(argument.infinite) argument.value.push(value); else argument.value = value; } else { argument.setDefault(guild); } return response; } async _createArguments(args) { const shortArgs = {}, longArgs = {}; let argKeys = []; for(let arg of args) { arg = new Argument(this.client, arg); const letters = []; const names = [ arg.name, ...arg.aliases ]; argKeys = [...argKeys, ...names]; for(const name of names) { longArgs[name] = arg; if(!arg.types.includes('FLAG')) continue; let letter = name.slice(0, 1); if(letters.includes(letter)) continue; if(argKeys.includes(letter)) letter = letter.toUpperCase(); if(argKeys.includes(letter)) { // this.client.logger.warn(`Command has too many arguments with the same first letter: ${argKeys.join(', ')}`); break; } argKeys.push(letter); letters.push(letter); shortArgs[letter] = arg; } } return { shortArgs, longArgs }; } async _getCommand(message) { const [ arg1, arg2, ...args ] = message.content.split(' '); if(message.guild) await message.guild.settings(); const { prefix } = message; let command = null, remains = []; if(arg1 && arg1.startsWith(prefix)) { const commandName = arg1.slice(prefix.length); //Grabs the command name by slicing off the prefix. command = await this._matchCommand(message, commandName); remains = [arg2, ...args]; } else if(arg1 && arg2 && arg1.startsWith('<@')) { //Checks if the first argument is a mention and if a command is after it. const pattern = new RegExp(`^(<@!?${this.client.user.id}>)`, 'iu'); if(arg2 && pattern.test(arg1)) { command = await this._matchCommand(message, arg2); } remains = args; } return { command, parameters: remains }; } async _matchCommand(message, commandName) { const [ command ] = this.client.resolver.components(commandName, 'command', true); if(!command) return null; //Eventually search for custom commands here. message._caller = commandName; //Used for hidden commands as aliases. return command; } /* Command Handling */ async handleCommand(message) { const inhibitors = await this._handleInhibitors(message); const silent = inhibitors.filter((i) => i.inhibitor.silent); const nonsilent = inhibitors.filter((i) => !i.inhibitor.silent); if(nonsilent.length === 0 && silent.length > 0) return undefined; if(nonsilent.length > 0) return this.handleError(message, { type: 'inhibitor', ...nonsilent[0] }); const resolved = await message.resolve(); if(resolved.error) { this.client.logger.error(`Command Error | ${message.command.resolveable} | Message ID: ${message.id}\n${resolved.message.stack || resolved.message}`); if(resolved.message.code === 50013) { const missing = message.channel.permissionsFor(message.guild.me).missing(['EMBED_LINKS']); if(missing.length > 0) { return message.respond(message.format('COMMANDHANDLER_COMMAND_MISSINGPERMISSIONS'), { emoji: 'failure' }); } } return this.handleError(message, { type: 'command' }); } return true; } async _handleInhibitors(message) { const inhibitors = this.client.registry.components.filter((c) => c.type === 'inhibitor' && !c.disabled); if(inhibitors.size === 0) return []; const promises = []; for(const inhibitor of inhibitors.values()) { // Loops through all inhibitors, executing them. if(inhibitor.guild && !message.guild) continue; promises.push((async () => { let inhibited = inhibitor.execute(message, message.command); if(inhibited instanceof Promise) inhibited = await inhibited; return inhibited; })()); } const reasons = (await Promise.all(promises)).filter((p) => p.error); // Filters out inhibitors with only errors. if(reasons.length === 0) return []; reasons.sort((a, b) => b.inhibitor.priority - a.inhibitor.priority); // Sorts inhibitor errors by most important. return reasons; } async parseType(type, str, guild) { //Types used for parsing argument types. const types = { STRING: (str) => ({ error: false, value: str }), INTEGER: (str) => { const int = parseInt(str); if(Math.round(int) !== int) return { error: true }; if(isNaN(int)) return { error: true }; return { error: false, value: int }; }, FLOAT: (str) => { const float = parseInt(str); if(isNaN(float)) return { error: true }; return { error: false, value: float }; }, BOOLEAN: (str) => { const bool = this.client.resolver.resolveBoolean(str); if(bool === null) return { error: true }; return { error: false, value: bool }; }, USER: async (str) => { const user = await this.client.resolver.resolveUser(str, true); if(!user) return { error: true }; return { error: false, value: user }; }, MEMBER: async (str, guild) => { const member = await this.client.resolver.resolveMember(str, true, guild); if(!member) return { error: true }; return { error: false, value: member }; }, TEXTCHANNEL: async (str, guild) => { const channel = await this.client.resolver.resolveChannel(str, true, guild, (channel) => channel.type === 'text'); if(!channel) return { error: true }; return { error: false, value: channel }; }, VOICECHANNEL: async(str, guild) => { const channel = await this.client.resolver.resolveChannel(str, true, guild, (channel) => channel.type === 'voice'); if(!channel) return { error: true }; return { error: false, value: channel }; }, CHANNEL: async (str, guild) => { const channel = await this.client.resolver.resolveChannel(str, true, guild); if(!channel) return { error: true }; return { error: false, value: channel }; }, ROLE: async (str, guild) => { const role = await this.client.resolver.resolveRole(str, true, guild); if(!role) return { error: true }; return { error: false, value: role }; }, TIME: async(str) => { const time = await this.client.resolver.resolveTime(str); if(!time) return { error: true }; return { error: false, value: time }; }, DATE: async(str) => { const date = await this.client.resolver.resolveDate(str); if(!date) return { error: true }; return { error: false, value: date }; } }; return types[type](str, guild); } /* Made by my friend Qwerasd#5202 */ // I'm not entirely sure how this works, except for the fact that it loops through each CHARACTER and tries to match quotes together. // Supposedly quicker than regex, and I'd agree with that statement. Big, messy, but quick. Also lets you know if the grouped word(s) was a quote or not, which is useful for non-spaced quotes e.g. "Test" vs. "Test Test" _getQuotes(string) { if(!string) return []; let quoted = false, wordStart = true, startQuote = '', endQuote = false, isQuote = false, word = ''; const words = [], chars = string.split(''); chars.forEach((char) => { if((/\s/u).test(char)) { if(endQuote) { quoted = false; endQuote = false; isQuote = true; } if(quoted) { word += char; } else if(word !== '') { words.push([ word, isQuote ]); isQuote = false; startQuote = ''; word = ''; wordStart = true; } } else if(this._quoteMarks.includes(char)) { if (endQuote) { word += endQuote; endQuote = false; } if(quoted) { if(char === Constants.QuotePairs[startQuote]) { endQuote = char; } else { word += char; } } else if(wordStart && this._startQuotes.includes(char)) { quoted = true; startQuote = char; } else { word += char; } } else { if(endQuote) { word += endQuote; endQuote = false; } word += char; wordStart = false; } }); if (endQuote) { words.push([ word, true ]); } else { word.split(/\s/u).forEach((subWord, i) => { if (i === 0) { words.push([ startQuote+subWord, false ]); } else { words.push([ subWord, false ]); } }); } return words; } async handleError(message, error) { //Handle different types of errors const messages = { command: async () => message.format('COMMANDHANDLER_COMMAND_ERROR', { invite: this.client._options.bot.invite, id: message.id, command: message.command.moduleResolveable, date: moment().format('YYYY-MM-DD') }), inhibitor: ({ inhibitor, args }) => `${message.format(inhibitor.index, { command: message.command.moduleResolveable, ...args })} **\`[${inhibitor.resolveable}]\`**`, argument: ({ index, args, argument }) => { const type = message.format('COMMANDHANDLER_TYPES', { type: argument.type }, true); return message.format(index, { type, arg: `${message.command.name}:${argument.name}`, ...args }); } }; //return this.client.rateLimiter.limitSend(message.channel, await messages[error.type](error)); return message.limitedRespond(await messages[error.type](error), { emoji: 'failure', limit: 10, utility: error.type }); } } module.exports = CommandHandler;