src/commands/dispatcher.js
'use babel';
'use strict';
import EventEmitter from 'events';
import { stripIndents } from 'common-tags';
import escapeRegex from 'escape-string-regexp';
import Module from './module';
import FriendlyError from '../errors/friendly';
import Setting from '../storage/models/setting';
/** Handles parsing messages and running commands from them */
export default class CommandDispatcher extends EventEmitter {
/** @param {Bot} bot - The bot the dispatcher is for */
constructor(bot) {
if(!bot) throw new Error('A bot must be specified.');
super();
/** @type {Bot} */
this.bot = bot;
this._guildCommandPatterns = {};
this._results = new Map();
this._blacklistedUsers = bot.storage.settings.getValue(null, 'blacklisted-users');
if(!this._blacklistedUsers) {
bot.storage.settings.save(new Setting(null, 'blacklisted-users', []));
this._blacklistedUsers = bot.storage.settings.getValue(null, 'blacklisted-users');
}
}
/**
* Handle a new message or a message update
* @param {Message} message - The message to handle
* @param {Message} [oldMessage] - The old message before the update
* @return {Promise<null>} No value
*/
async handleMessage(message, oldMessage = null) {
if(message.author.bot) return null;
else if(this.bot.config.values.selfbot && message.author.id !== this.bot.client.user.id) return null;
else if(!this.bot.config.values.selfbot && message.author.id === this.bot.client.user.id) return null;
// Make sure the edit actually changed the message content
if(oldMessage && message.content === oldMessage.content) return null;
// Make sure the bot is allowed to run in the channel, or the user is an admin
if(!this.bot.config.values.selfbot && message.guild
&& Module.isEnabled(this.bot.storage.settings, message.guild, 'channels')
&& !this.bot.storage.allowedChannels.isEmpty(message.guild)
&& !this.bot.storage.allowedChannels.exists(message.guild, message.channel.id)
&& !this.bot.permissions.isAdmin(message.guild, message.author)) return null;
// Make sure the user isn't blacklisted
if(this._blacklistedUsers.includes(message.author.id)) return null;
// Parse the message, and get the old result if it exists
const [command, args, fromPattern, isCommandMessage] = this._parseMessage(message);
const oldResult = oldMessage ? this._results.get(oldMessage.id) : null;
// Run the command, or make an error message result
let result;
if(command) {
if(!command.isEnabled(message.guild)) result = { reply: [`The \`${command.name}\` command is disabled.`], editable: true };
else if(!oldMessage || oldResult) result = await this.run(command, args, fromPattern, message);
} else if(isCommandMessage) {
result = { reply: [`Unknown command. Use ${this.bot.util.usage('help', message.guild)} to view the list of all commands.`], editable: true };
} else if(this.bot.config.values.nonCommandEdit) {
result = { editable: true };
}
return await this.handleMessageResult(message, result, oldResult);
}
/**
* Handle a message result
* @param {Message} message - The message the result is from
* @param {?CommandResult} result - The result
* @param {CommandResult} [oldResult] - The old result
* @return {Promise<null>} No value
*/
async handleMessageResult(message, result, oldResult = null) {
if(result) {
// Make sure the bot has permission to send a message
let hasPermission = true;
if(message.guild && !message.channel.permissionsFor(this.bot.client.user).hasPermission('SEND_MESSAGES')) {
hasPermission = false;
if((result.plain || result.reply) && !result.direct && !(oldResult && (oldResult.plain || oldResult.reply || oldResult.direct))) {
await message.author.sendMessage(`I don't have permission to send messages in ${message.channel}, so I'll respond directly instead:`);
}
}
// Change a plain or reply response into direct if there isn't a guild
if(!message.guild || !hasPermission) {
if(!result.direct) result.direct = result.plain || result.reply;
delete result.plain;
delete result.reply;
}
// Update old messages or send new ones
if(oldResult && (oldResult.plain || oldResult.reply || oldResult.direct)) {
await this.updateMessagesForResult(message, result, oldResult);
} else {
await this.sendMessagesForResult(message, result);
}
// Cache the result
if(this.bot.config.values.commandEditable > 0) {
if(result.editable) {
result.timeout = oldResult && oldResult.timeout
? oldResult.timeout
: setTimeout(() => { this._results.delete(message.id); }, this.bot.config.values.commandEditable * 1000);
this._results.set(message.id, result);
} else {
this._results.delete(message.id);
}
}
}
return null;
}
/**
* Run a command
* @param {Command} command - The command to run
* @param {string[]} args - The arguments for the command
* @param {boolean} fromPattern - Whether or not the arguments are from a pattern match
* @param {Message} message - The message that triggered the run
* @return {Promise<CommandResult>} The result of running the command
* @emits commandRun When a command is run, with the command, result, message, args, and fromPattern passed
* @emits commandError When an error occurs while running a command, with the command, message, args, and fromPattern passed
*/
async run(command, args, fromPattern, message) {
const logInfo = {
args: String(args),
user: `${message.author.username}#${message.author.discriminator}`,
userID: message.author.id,
guild: message.guild ? message.guild.name : null,
guildID: message.guild ? message.guild.id : null,
messageID: message.id
};
// Make sure the command is usable
if(command.guildOnly && !message.guild) {
this.bot.logger.info(`Not running ${command.module}:${command.memberName}; guild only.`, logInfo);
return { reply: [`The \`${command.name}\` command must be used in a server channel.`], editable: true };
}
if(!command.hasPermission(message.guild, message.author)) {
this.bot.logger.info(`Not running ${command.module}:${command.memberName}; don't have permission.`, logInfo);
return { reply: [`You do not have permission to use the \`${command.name}\` command.`], editable: true };
}
// Run the command
this.bot.logger.info(`Running ${command.module}:${command.memberName}.`, logInfo);
const typingCount = message.channel.typingCount;
try {
const result = this.constructor.makeResultObject(await command.run(message, args, fromPattern));
this.emit('commandRun', command, result, message, args, fromPattern);
return result;
} catch(err) {
this.emit('commandError', command, err, message, args, fromPattern);
if(message.channel.typingCount > typingCount) message.channel.stopTyping();
if(err instanceof FriendlyError) {
return { reply: [err.message], editable: true };
} else {
this.bot.logger.error(err);
const owner = this.bot.config.values.owner ? message.client.users.get(this.bot.config.values.owner) : null;
return {
reply: [stripIndents`
An error occurred while running the command: \`${err.name}: ${err.message}\`
You shouldn't ever receive an error like this.
${owner ? `Please contact ${owner.username}#${owner.discriminator}${this.bot.config.values.invite ? ` in this server: ${this.bot.config.values.invite}` : '.'}` : ''}
`],
editable: true
};
}
}
}
/**
* Sends messages for a command result
* @param {Message} message - The message the result is for
* @param {CommandResult} result - The command result
* @return {Promise<null>} No value
*/
async sendMessagesForResult(message, result) {
const messages = await Promise.all([
result.plain ? this.sendMessages(message, result.plain, 'plain') : null,
result.reply ? this.sendMessages(message, result.reply, 'reply') : null,
result.direct ? this.sendMessages(message, result.direct, 'direct') : null
]);
if(result.plain) result.normalMessages = messages[0];
else if(result.reply) result.normalMessages = messages[1];
if(result.direct) result.directMessages = messages[2];
return null;
}
/**
* Sends messages
* @param {Message} message - The message the messages are being sent in response to
* @param {string[]} contents - Contents of the messages to send
* @param {string} type - One of 'plain', 'reply', or 'direct'
* @return {Promise<Message[]>} The sent messages
*/
async sendMessages(message, contents, type) {
const sentMessages = [];
for(const content of contents) {
if(type === 'plain') sentMessages.push(await message.channel.sendMessage(content));
else if(type === 'reply') sentMessages.push(await message.reply(content));
else if(type === 'direct') sentMessages.push(await message.author.sendMessage(content));
}
return sentMessages;
}
/**
* Updates messages for a command result
* @param {Message} message - The message the result is for
* @param {CommandResult} result - The command result
* @param {CommandResult} oldResult - The old command result
* @return {Promise<null>} No value
*/
async updateMessagesForResult(message, result, oldResult) {
// Update the messages
const messages = await Promise.all([
result.plain || result.reply ? this.updateMessages(message, oldResult.normalMessages, result.plain || result.reply, result.plain ? 'plain' : 'reply') : null,
result.direct ? oldResult.direct ? this.updateMessages(message, oldResult.directMessages, result.direct, 'direct') : this.sendMessages(message, result.direct, 'direct') : null
]);
if(result.plain || result.reply) result.normalMessages = messages[0];
if(result.direct) result.directMessages = messages[1];
// Delete old messages if we're not using them
if(!result.plain && !result.reply && (oldResult.plain || oldResult.reply)) for(const msg of oldResult.normalMessages) msg.delete();
if(!result.direct && oldResult.direct) for(const msg of oldResult.directMessages) msg.delete();
return null;
}
/**
* Updates messages
* @param {Message} message - The message the old messages are being updated in response to
* @param {Message[]} oldMessages - The old messages to update
* @param {string[]} contents - Contents of the messages to send
* @param {string} type - One of 'plain', 'reply', or 'direct'
* @return {Promise<Message[]>} The updated messages
*/
async updateMessages(message, oldMessages, contents, type) {
const updatedMessages = [];
// Update/send messages
for(let i = 0; i < contents.length; i++) {
if(i < oldMessages.length) updatedMessages.push(await oldMessages[i].edit(type === 'reply' ? `${message.author}, ${contents[i]}` : contents[i]));
else updatedMessages.push((await this.sendMessages(message, [contents[i]], type))[0]);
}
// Delete extra old messages
if(oldMessages.length > contents.length) {
for(let i = oldMessages.length - 1; i >= contents.length; i--) oldMessages[i].delete();
}
return updatedMessages;
}
/**
* Parses a message to find details about command usage in it
* @param {Message} message - The message
* @return {Array} Command, arguments, whether or not it's from a pattern match, and whether or not it's a command message
*/
_parseMessage(message) {
// Find the command to run by patterns
for(const command of this.bot.registry.commands) {
if(!command.patterns) continue;
for(const pattern of command.patterns) {
const matches = pattern.exec(message.content);
if(matches) return [command, matches, true, true];
}
}
// Find the command to run with default command handling
const patternIndex = message.guild ? message.guild.id : '-';
if(!this._guildCommandPatterns[patternIndex]) this._guildCommandPatterns[patternIndex] = this._buildCommandPattern(message.guild, message.client.user);
let [command, args, isCommandMessage] = this._matchDefault(message, this._guildCommandPatterns[patternIndex], 2);
if(!command && !message.guild && !this.bot.config.values.selfbot) [command, args, isCommandMessage] = this._matchDefault(message, /^([^\s]+)/i);
if(command) return [command, args, false, true];
return [null, null, false, isCommandMessage];
}
/**
* Matches a message against a guild command pattern
* @param {Message} message - The message
* @param {RegExp} pattern - The pattern to match against
* @param {number} commandNameIndex - The index of the command name in the pattern matches
* @return {Array} The command, arguments, and whether or not it's a command message
*/
_matchDefault(message, pattern, commandNameIndex = 1) {
const matches = pattern.exec(message.content);
if(!matches) return [null, null, false];
const commands = this.bot.registry.findCommands(matches[commandNameIndex]);
if(commands.length !== 1) return [null, null, true];
if(!commands[0] || !commands[0].defaultHandling) return [null, null, true];
const argString = message.content.substring(matches[1].length + (matches[2] ? matches[2].length : 0));
let args;
if(commands[0].argsType === 'single') {
args = [argString.trim().replace(commands[0].argsSingleQuotes ? /^("|')([^]*)\1$/g : /^(")([^]*)"$/g, '$2')];
} else if(commands[0].argsType === 'multiple') {
args = this.constructor.parseArgs(argString, commands[0].argsCount, commands[0].argsSingleQuotes);
}
return [commands[0], args, true];
}
/**
* Makes a command result object from a command's run result
* @param {CommandResult|string[]|string} result - The command's run result
* @return {CommandResult} The result object
*/
static makeResultObject(result) {
if(typeof result !== 'object' || Array.isArray(result)) result = { reply: result };
if(result.plain && result.reply) throw new Error('The command result may contain either "plain" or "reply", not both.');
if(result.plain && !Array.isArray(result.plain)) result.plain = [result.plain];
if(result.reply && !Array.isArray(result.reply)) result.reply = [result.reply];
if(result.direct && !Array.isArray(result.direct)) result.direct = [result.direct];
if(!('editable' in result)) result.editable = true;
return result;
}
/**
* Parses an argument string into an array of arguments
* @param {string} argString - The argument string to parse
* @param {number} [argCount] - The number of arguments to extract from the string
* @param {boolean} [allowSingleQuote=true] - Whether or not single quotes should be allowed to wrap arguments, in addition to double quotes
* @return {string[]} The array of arguments
*/
static parseArgs(argString, argCount, allowSingleQuote = true) {
const re = allowSingleQuote ? /\s*(?:("|')([^]*?)\1|(\S+))\s*/g : /\s*(?:(")([^]*?)"|(\S+))\s*/g;
const result = [];
let match = [];
// default: large enough to get all items
argCount = argCount || argString.length;
// get match and push the capture group that is not null to the result
while(--argCount && (match = re.exec(argString))) result.push(match[2] || match[3]);
// if text remains, push it to the array as it is, except for wrapping quotes, which are removed from it
if(match && re.lastIndex < argString.length) {
const re2 = allowSingleQuote ? /^("|')([^]*)\1$/g : /^(")([^]*)"$/g;
result.push(argString.substr(re.lastIndex).replace(re2, '$2'));
}
return result;
}
/**
* Creates a regular expression to match the command prefix and name in a message
* @param {?Guild} guild - The Guild that the message is from
* @param {User} user - The User that the bot is running for
* @return {RegExp} Regular expression that matches a command prefix and name
*/
_buildCommandPattern(guild, user) {
let prefix = guild ? this.bot.storage.settings.getValue(guild, 'command-prefix', this.bot.config.values.commandPrefix) : this.bot.config.values.commandPrefix;
if(prefix === 'none') prefix = '';
const escapedPrefix = escapeRegex(prefix);
const prefixPatternPiece = prefix ? `${escapedPrefix}\\s*|` : '';
const pattern = new RegExp(`^(${prefixPatternPiece}<@!?${user.id}>\\s+(?:${escapedPrefix})?)([^\\s]+)`, 'i');
this.bot.logger.verbose(`Guild command pattern built.`, {
guild: guild ? guild.name : null,
guildID: guild ? guild.id : null,
prefix: prefix, pattern: pattern.source
});
return pattern;
}
}
/**
* @typedef {Object} CommandResult
* @property {string[]} [plain] - Strings to send plain messages for
* @property {string[]} [reply] - Strings to send reply messages for
* @property {string[]} [direct] - Strings to send direct messages for
* @property {boolean} [editable=true] - Whether or not the command message is editable
*/