// Copyright 2018-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) require('./mainModule.js')(Command); // Extends the MainModule class. /** * @classdesc Manages the command event firing for all commands. This is not a * normal submodule, and is treated differently in the SpikeyBot class. * @class * @augments MainModule */ function Command() { const self = this; /** @inheritdoc */ this.myName = 'Command'; /** @inheritdoc */ this.initialize = function() { /** REST API used for registering slash commands. */ self.rest = new this.Discord.REST({version: '10'}).setToken(this.client.token); self.client.guilds.cache.forEach((g) => { const dir = self.common.guildSaveDir + g.id; const filename = dir + commandSettingsFile; self.common.readAndParse(filename, (err, parsed) => { if (err) { if (err.code == 'ENOENT') { // File does not exist. No custom settings exist yet. return; } self.error('Failed to read user settings for commands: ' + filename); console.error(err); return; } if (parsed) { if (!userSettings[g.id]) userSettings[g.id] = {}; Object.entries(parsed).forEach((el) => { userSettings[g.id][el[0]] = new CommandSetting(el[1]); }); } }); }); const cmdSettings = new CommandSetting({ validOnlyInGuild: true, defaultDisabled: true, permissions: self.Discord.PermissionsBitField.Flags.ManageGuild, }); self.on(new SingleCommand(['disable'], commandDisable, cmdSettings)); self.on(new SingleCommand(['enable'], commandEnable, cmdSettings)); self.on( new SingleCommand( ['mutecmd', 'blockcmd', 'suppresscmd'], commandBlockCmd, cmdSettings)); self.on( new SingleCommand( ['unmutecmd', 'allowcmd'], commandAllowCmd, cmdSettings)); self.on( new SingleCommand( [ 'show', 'enabled', 'disabled', 'showenabled', 'showdisabled', 'settings', 'permissions', ], commandShow, cmdSettings)); self.on(new SingleCommand(['reset'], commandReset, cmdSettings)); }; /** @inheritdoc */ this.shutdown = function() { self.removeListener( ['disable', 'enable', 'show', 'reset', 'mutecmd', 'allowcmd']); }; /** @inheritdoc */ this.save = function(opt) { Object.entries(userSettings).forEach((el) => { let updated = el[1]._updated || false; const data = {}; Object.entries(el[1]).forEach((cmd) => { if (typeof cmd[1].toJSON !== 'function') return; if (cmd[1]._updated) updated = true; cmd[1]._updated = false; data[cmd[0]] = cmd[1].toJSON(); }); if (!updated) return; const dir = self.common.guildSaveDir + el[0]; const filename = dir + commandSettingsFile; if (opt == 'async') { self.common.mkAndWrite(filename, dir, data, (err) => { if (err) { self.error(`Failed to write command settings to file: ${filename}`); console.error(err); return; } }); } else { self.common.mkAndWriteSync(filename, dir, data, (err) => { if (err) { self.error(`Failed to write command settings to file: ${filename}`); console.error(err); } }); } }); }; /** @inheritdoc */ this.import = function(data) { if (!data) return; cmds = data.cmds; eventList = data.events; }; /** @inheritdoc */ this.export = function() { const output = { cmds: cmds, events: eventList, }; cmds = null; eventList = null; return output; }; /** * The function to call when a command is triggered. * * @callback commandHandler * @param {Discord~Message} msg The message sent in Discord. */ /** * Currently registered event listeners for non-command events. */ let eventList = {}; /** * All tracked commands mapped by command name. * * @private * @type {object.<SingleCommand>} */ let cmds = {}; /** * Register all commands currently loaded as slash commands to the Discord * API. * * @public * @returns {Promise} REST API request Promise. */ this.registerSlashCommands = function() { const names = self.getAllNames(); const commands = names.map( (el) => new self.Discord.SlashCommandBuilder() .setName(el) .setDescription('A SpikeyBot command.') .addStringOption( (option) => option.setName('input').setDescription( 'Remaining command arguments')) .toJSON()); self.log(`Registering slash commands: ${commands.length}`); return self.rest.put( self.Discord.Routes.applicationCommands(self.client.user.id), {body: commands}); }; /** * @classdesc Object storing information about a single command, it's handler, * and default options. * @class * @public * * @param {string|string[]} cmd All commands the handler will fire on. * @param {commandHandler} handler The event handler when the command has been * triggered. * @param {CommandSetting} [opts] The options for this command. * @param {SingleCommand|SingleCommand[]} [subCmds] Sub commands that use this * command as a fallback. Command names must be separated by white space in * order to trigger the sub command. */ function SingleCommand(cmd, handler, opts, subCmds) { const me = this; if (typeof handler !== 'function') { throw new Error('Command handler must be a function.'); } if (typeof cmd === 'string') cmd = [cmd]; if (!Array.isArray(cmd)) { throw new Error( 'Commands must be specified as a string, or array of strings.'); } if (subCmds && !Array.isArray(subCmds)) subCmds = [subCmds]; else if (!subCmds) subCmds = []; /** * The name of the parent command if this is a subcommand. * * @public * @readonly * @type {?string} */ this.parentName = null; /** * Update the parent name for this command and all child commands. * * @public * @param {string} to The parent name to set. */ this.updateParentName = function(to) { me.parentName = to; const fullName = me.getFullName(); for (const i in me.subCmds) { if (me.subCmds[i].updateParentName) { me.subCmds[i].updateParentName(fullName); } } }; /** * Get the full name for this command including parent command. * * @returns {string} This command's name prefixed with the parent command's * name. */ this.getFullName = function() { if (me.parentName) { return `${me.parentName} ${me.getName()}`; } else { return me.getName(); } }; /** * Get the primary key for this object. The first or only value passed in * for `cmd`, and may be used to show the user the command that this object * stores information about. * * @public * * @returns {string} The command string. */ this.getName = function() { return me.aliases[0]; }; /** * All versions of this command that may be used to trigger the same * handler. * * @public * * @type {string[]} */ this.aliases = cmd.map((el) => el.toLowerCase()); /** * Sub commands for this single command. Triggered by commands separated by * whitespace. Object mapped by subcommand name, similar to * {@link Command~cmds}. * * @public * @type {object.<SingleCommand>} */ this.subCmds = {}; for (let i = 0; i < subCmds.length; i++) { this.subCmds[subCmds[i].getName()] = subCmds[i]; } this.updateParentName(me.parentName); /** * The function to call when this command has been triggered. * * @public * * @param {Discord~Message} msg The message that is triggering this command. */ this.trigger = function(msg) { if (msg.cmd && msg.cmd != me.getFullName() && me.subCmds) { const sub = msg.cmd .replace( new RegExp(escapeRegExp(`${me.getFullName()}\\s+`)), '') .split(' ')[0] .toLocaleLowerCase(); if (sub) { let match = me.subCmds[sub]; if (!match) { match = Object.values(me.subCmds).find((el) => { return el.aliases.includes(sub); }); } if (match) { msg.text = msg.text.replace( new RegExp(`^.*?${escapeRegExp(me.getFullName())}`, 'i'), ''); me.subCmds[sub].trigger(msg); return; } } } let text = msg.text; const uIds = text.match(/\d{17,19}/g); msg.softMentions = { users: new self.Discord.Collection(), members: msg.guild ? new self.Discord.Collection() : null, roles: msg.guild ? new self.Discord.Collection() : null, }; if (uIds) { uIds.forEach((el) => { const u = self.client.users.resolve(el); if (u) { msg.softMentions.users.set(u.id, u); text = text.replace(el, ''); } if (msg.guild) { const m = msg.guild.members.resolve(el); if (m) msg.softMentions.members.set(m.id, m); const r = msg.guild.roles.resolve(el); if (r) msg.softMentions.roles.set(r.id, r); } }); } if (msg.guild) { const sT = text.toLocaleLowerCase(); msg.guild.members.cache.forEach((el) => { if (sT.indexOf(el.user.username.toLocaleLowerCase()) > -1) { // sT = sT.replace(el.user.username.toLocaleLowerCase(), ''); msg.softMentions.members.set(el.id, el); msg.softMentions.users.set(el.user.id, el.user); } else if (sT.indexOf(el.user.tag.toLocaleLowerCase()) > -1) { // sT = sT.replace(el.user.tag.toLocaleLowerCase(), ''); msg.softMentions.members.set(el.id, el); msg.softMentions.users.set(el.user.id, el.user); } else if ( el.nickname && sT.indexOf(el.nickname.toLocaleLowerCase()) > -1) { // sT = sT.replace(el.nickname.toLocaleLowerCase(), ''); msg.softMentions.members.set(el.id, el); msg.softMentions.users.set(el.user.id, el.user); } }); msg.guild.roles.cache.forEach((el) => { if (sT.indexOf(el.name.toLocaleLowerCase()) > -1) { // sT = sT.replace(el.role.name.toLocaleLowerCase(), ''); msg.softMentions.roles.set(el.id, el); } }); } handler(msg); }; /** * The current options and settings for this command. * * @public * @type {Command~CommandSetting} */ this.options = new CommandSetting(opts); /** * Fetches the user options for this command, taking into account this could * be a subcommand. * * @public * @returns {object<CommandSetting>} The settings for this command or * sub-command mapped by guild ids. */ this.getUserOptions = function() { const myName = me.getFullName(); return Object.entries(userSettings) .map((el) => { const settings = el[1][myName]; return [el[0], settings]; }) .filter((el) => el[1]) .reduce((p, c) => { p[c[0]] = c[1]; return p; }, {}); }; } /** @see {@link Command~SingleCommand} */ this.SingleCommand = SingleCommand; /** * @classdesc Stores all settings related to a command. * @class * @public * * @param {Command~CommandSetting} [opts] The options to set, or nothing for * default values. */ function CommandSetting(opts) { const me = this; if (!opts) opts = {}; /** * The guild ID of the guild is settings object is for, or null if this * instance is not specific to a single guild. * * @public * @type {?string} */ this.myGuild = opts.guildId || null; /** * If the command is only allowed to be used in guilds. * * @public * @type {boolean} */ this.validOnlyInGuild = opts.validOnlyInGuild || false; /** * Whether this command is disabled for all by default and requires them to * be in the list of enabled IDs. If this is false, the command is enabled * for everyone, unless they fall under the 'disabled' list. */ this.defaultDisabled = opts.defaultDisabled || false; /** * @description Have these settings been modified since last save. * @protected * @type {boolean} * @default */ this._updated = false; /** * @description Enqueue these settings to be saved to disk. * @public */ this.updated = function() { me._updated = true; }; /** * The IDs of all places where this command is currently disabled. Any ID * will be mapped to a truthy value. Roles will be mapped to the guild ID * and the role ID. Use {@link Command~CommandSetting.set} to change these * values. * * @public * @readonly * @type {{ * guilds: object.<boolean>, * channels: object.<boolean>, * users: object.<boolean>, * roles: object.<boolean> * }} */ this.disabled = {guilds: {}, channels: {}, users: {}, roles: {}}; if (opts.disabled) { if (typeof opts.disabled.guilds === 'object') { Object.assign(this.disabled.guilds, opts.disabled.guilds); } if (typeof opts.disabled.channels === 'object') { Object.assign(this.disabled.channels, opts.disabled.channels); } if (typeof opts.disabled.users === 'object') { Object.assign(this.disabled.users, opts.disabled.users); } if (typeof opts.disabled.roles === 'object') { Object.assign(this.disabled.roles, opts.disabled.roles); } } /** * The IDs of all places where this command is currently enabled. Any ID * will be mapped to a truthy value. Roles will be mapped to the guild ID * and the role ID. Use {@link Command~CommandSetting.set} to change these * values. * * @public * @readonly * @type {{ * guilds: object.<boolean>, * channels: object.<boolean>, * users: object.<boolean>, * roles: object.<boolean> * }} */ this.enabled = {guilds: {}, channels: {}, users: {}, roles: {}}; if (opts.enabled) { if (typeof opts.enabled.guilds === 'object') { Object.assign(this.enabled.guilds, opts.enabled.guilds); } if (typeof opts.enabled.channels === 'object') { Object.assign(this.enabled.channels, opts.enabled.channels); } if (typeof opts.enabled.users === 'object') { Object.assign(this.enabled.users, opts.enabled.users); } if (typeof opts.enabled.roles === 'object') { Object.assign(this.enabled.roles, opts.enabled.roles); } } /** * Bitfield representation of the required permissions for a user to have to * run this command. Same bitfield used by Discord~Permissions. * * @public * @type {bigint} * @default 0 */ this.permissions = opts.permissions; if (typeof this.permissions !== 'bigint') { this.permissions = BigInt(0); } /** * Will this command be completely silenced so that no output will be sent. * Only applicable when command is disabled. * * @private * @type {boolean} * @default */ this.isMuted = opts.isMuted || false; /** * @description Enable, disable, or neutralize this command for the * associated guild, channel, user, or role. * * @public * @fires Command.events#settingsChanged * @param {string} value Whether to set this ID to enabled, disabled, or to * whatever the default value is. Allowed values: * `enabled`|`disabled`|`default`. * @param {string} type The type of ID that is being given. Allowed values: * `guild`|`channel`|`user`|`role`. * @param {string} id The id to set the value to. * @param {string} [id2] The guild ID if `type` is 'role', of where the role * is created. */ this.set = function(value, type, id, id2) { switch (value) { case 'enabled': case 'disabled': case 'default': break; default: throw new Error( 'Invalid value to set the command to \'' + value + '\'. (Expected \'enabled\', \'disabled\', or \'default\'.)'); } switch (type) { case 'guild': if (!id || !self.client.guilds.resolve(id)) { throw new Error('Guild ID is invalid for id: ' + id); } if (value != 'enabled') delete me.enabled.guilds[id]; else me.enabled.guilds[id] = true; if (value != 'disabled') delete me.disabled.guilds[id]; else me.disabled.guilds[id] = true; break; case 'channel': if (!id || !self.client.channels.resolve(id)) { throw new Error('Channel ID is invalid for id: ' + id); } if (value != 'enabled') delete me.enabled.channels[id]; else me.enabled.channels[id] = true; if (value != 'disabled') delete me.disabled.channels[id]; else me.disabled.channels[id] = true; break; case 'user': if (!id || !self.client.users.resolve(id)) { throw new Error('User ID is invalid for id: ' + id); } if (value != 'enabled') delete me.enabled.users[id]; else me.enabled.users[id] = true; if (value != 'disabled') delete me.disabled.users[id]; else me.disabled.users[id] = true; break; case 'role': if (!id2 || !self.client.guilds.resolve(id2)) { throw new Error('Guild ID is invalid for id2: ' + id2); } if (!id || !self.client.guilds.resolve(id2).roles.resolve(id)) { throw new Error('Role ID is invalid for id: ' + id); } if (value != 'enabled') delete me.enabled.roles[id2 + '/' + id]; else me.enabled.roles[id2 + '/' + id] = true; if (value != 'disabled') delete me.disabled.roles[id2 + '/' + id]; else me.disabled.roles[id2 + '/' + id] = true; break; default: throw new Error( 'Invalid type to set command enabled/disabled status to \'' + type + '\'. (Expected \'guild\', \'channel\', \'user\', or \'role\'.)'); } me.updated(); self.fire('settingsChanged', me.myGuild, value, type, id, id2); }; /** * Check if this command is disabled with the given context. * * @public * * @param {Discord~Message} msg The message with the current context of * which to check if the command is disabled. * @returns {number} 0 if not disabled, 2 if disabled is specific to user, 1 * if disabled for any other reason. */ this.isDisabled = function(msg) { if (!msg) { throw new Error('Checking for disabled requires a Discord~Message.'); } if (self.common.trustedIds.includes(msg.author.id)) return 0; if (!msg.guild && me.validOnlyInGuild) return 1; let hasPerm = false; let permOverride = false; if (msg.guild) { // The command is disabled by default, but the GuildMember has a // required permission to run this command, or is Admin, or is guild // owner. let perms = BigInt(0); if (msg.channel) { const permObj = msg.channel.permissionsFor(msg.member); if (permObj) perms = permObj.bitfield; } else if (msg.member) { perms = msg.member.permissions.bitfield; } permOverride = (perms & self.Discord.PermissionsBitField.Flags.Administrator) || (msg.guild.ownerId === msg.author.id); hasPerm = (perms & me.permissions) || permOverride; hasPerm = (hasPerm && true) || false; } const disallow = me.defaultDisabled ? me.enabled : me.disabled; const matched = findMatch(disallow, msg); const isDisabled = !permOverride && ( // Command is disabled by default, and context does not explicitly // enable the command. ((!matched && !hasPerm) && me.defaultDisabled) || // Command is enabled by default, but context explicitly disables // the command. (matched && !me.defaultDisabled)); if (!isDisabled) return 0; if (me.defaultDisabled) { return 1; } else { return matched; } /** * @description Searches the given object against the reference data to * see if they find any matching IDs. * * @private * @param { * Command~CommandSetting.disabled| * Command~CommandSetting.enabled * } search The search data. * @param {Discord~Message} data The context to search for. * @returns {number} 0 if not disabled, 2 if disabled is specific to user, * 1 if disabled for any other reason. */ function findMatch(search, data) { if (search.users[data.author.id]) return 2; if (data.channel && search.channels[data.channel.id]) return 1; if (data.guild) { if (search.guilds[data.guild.id]) return 1; if (data.member && data.member.roles.cache.find( (r) => search.roles[`${data.guild.id}/${r.id}`])) { return 2; } } return 0; } }; /** * Creates a JSON formatted object with the necessary properties for * re-creating this object. * * @public * * @returns {object} Object ready to be stringified for file saving. */ this.toJSON = function() { return { guildId: me.myGuild, validOnlyInGuild: me.validOnlyInGuild, defaultDisabled: me.defaultDisabled, disabled: me.disabled, enabled: me.enabled, permissions: me.permissions, isMuted: me.isMuted, }; }; } /** @see {@link Command~CommandSetting} */ this.CommandSetting = CommandSetting; /** * Specific settings defined by users as restrictions on commands. Mapped by * guild id, then by the command. * * @private * @type {object.<object.<CommandSetting>>} */ const userSettings = {}; /** * Fetch all user-defined settings for a guild. * * @public * * @param {string} gId The guild id of which to fetch the settings. * @returns {object<CommandSetting>} The settings for the guild mapped by * command name. If it doesn't exist, an object will first be created. */ this.getUserSettings = function(gId) { if (!userSettings[gId] && self.client.guilds.resolve(gId)) { userSettings[gId] = {}; } return userSettings[gId]; }; /** * Fetch all commands and their default setting values. * * @see {@link Command~cmds} * @public * * @returns {object<SingleCommand>} All currently registered commands. */ this.getDefaultSettings = function() { return cmds; }; /** * The message to send to the user if they attempt a server-only command in a * non-server channel. * * @private * @type {string} * @constant */ const onlyservermessage = 'This command only works in servers, sorry!'; /** * Filename in the guild's subdirectory where command settings are stored. * * @private * @constant * @default * @type {string} */ const commandSettingsFile = '/commandSettings.json'; /** * Trigger a command firing and call it's handler passing in msg as only * argument. * * @param {Discord~Message|string} msg Message received from Discord to pass * to handler and to use to find the correct handler, OR a string to override * the command to trigger from msg. * @param {Discord~Message} [msg2] The message received from Discord if the * first argument is a string. * @returns {boolean} True if command was handled by us. */ this.trigger = function(msg, msg2) { let override = null; if (typeof msg === 'string') { override = msg; msg = msg2; } const func = self.find(override, msg, true); if (func) { const failure = self.validate(override, msg, func); if (failure && failure.endsWith('Muted')) { return true; } else if (failure === 'Guild Only') { self.common.reply(msg, onlyservermessage).catch(() => {}); return true; } else if (failure === 'Disabled') { self.common .reply(msg, 'This command has not been enabled for you here.') .catch(() => {}); return true; } else if (failure === 'Disabled Individual') { self.common .reply(msg, 'You do not have permission for this command here.') .catch(() => {}); return true; } else if (failure === 'User Disabled') { self.common .reply(msg, 'This command has been disabled by an admin here.') .catch(() => {}); return true; } else if (failure === 'User Disabled Individual') { self.common .reply( msg, 'An admin has prevented you from using this command here.') .catch(() => {}); return true; } else if (failure) { if (failure.startsWith('NoPerm:')) { self.common .reply( msg, 'You must have one of the following permissions ' + 'to use this command:\n' + failure.substring(7, failure.length)) .catch(() => {}); return true; } else { self.common .reply( msg, 'I am unable to attempt this command for ' + 'you due of an unknown reason.', failure) .catch(() => {}); self.error('Comand failed: ' + msg.cmd + ': ' + failure); return true; } } msg.text = msg.content.replace( new RegExp(escapeRegExp(`${msg.prefix}${msg.cmd}`), 'i'), ''); try { func.trigger(msg); } catch (err) { self.error(msg.cmd + ': FAILED'); console.error(err); self.common.reply(msg, 'An error occurred! Oh noes!').catch(() => {}); } return true; } else { return false; } }; /** * Registers a listener for a command. * * @param {string|string[]|Command~SingleCommand} cmd Command to listen for. * @param {commandHandler} [cb] Function to call when command is triggered. * @param {boolean} [onlyserver=false] Whether the command is only allowed * on a server. */ this.on = function(cmd, cb, onlyserver) { // Legacy mapping. if (!(cmd instanceof SingleCommand)) { cmd = new SingleCommand(cmd, cb, {validOnlyInGuild: onlyserver}); } const keys = Object.keys(cmds); const duplicates = cmd.aliases.filter((el) => keys.includes(el)); if (duplicates.length > 0) { self.error( 'Attempted to register a second handler for event that already ' + 'exists! (' + duplicates.join(', ') + ')'); } else { cmds[cmd.getName()] = cmd; } }; /** * Remove listener for a command. * * @public * * @param {string|string[]} cmd Command or alias of command to remove listener * for. */ this.removeListener = function(cmd) { if (!cmds) return; if (typeof cmd === 'string') { const obj = Object.entries(cmds).find((el) => { return el[1].aliases.includes(cmd); }); if (obj) { delete cmds[obj[0]]; } else { self.error( 'Requested deletion of event handler for event that was never ' + 'registered! (' + cmd + ')'); } } else if (Array.isArray(cmd)) { for (let i = 0; i < cmd.length; i++) { this.removeListener(cmd[i]); } } else { throw new Error('Event must be string or array of strings'); } }; /** * Alias for {@link Command.removeListener}. * * @deprecated * @public */ this.deleteEvent = this.removeListener; /** * Returns the callback function for the given event. * * @public * * @param {?string} cmd Command to force search for, and ignore command that * could be matched with msg. * @param {Discord~Message} msg Message that is to trigger this command. This * object will be updated with the command name that was found as msg.cmd. * @param {boolean} [setCmd=false] Set the cmd variable in the msg object to * match the found command. * @returns {?Command~SingleCommand} The single command object reference, or * null if it could not be found. */ this.find = function(cmd, msg, setCmd = false) { if (!cmds) return null; let split; if (!cmd) { split = msg.content.trim().split(/\s/); } else { split = cmd.trim().split(/\s/); } cmd = split.splice(0, 1)[0]; if (!cmd) return null; if (msg && cmd.startsWith(msg.prefix)) cmd = cmd.replace(msg.prefix, ''); cmd = cmd.toLowerCase(); let single = Object.values(cmds).find((el) => el.aliases.includes(cmd)); if (setCmd) msg.cmd = cmd; while (single && single.subCmds && split.length > 0) { const sub = Object.values(single.subCmds).find((el) => { return el.aliases.includes(split[0].toLowerCase()); }); if (sub) { single = sub; if (setCmd) msg.cmd += ' ' + split.splice(0, 1)[0].toLowerCase(); } else { break; } } return single; }; /** * Returns all the callback functions for the given event with wildcards * allowed. * * @public * * @param {string} cmd Command and subcommands to search for without guild * prefixes. * @param {Discord~Message} msg Message object to use to remove command prefix * if it exist. * @returns {Command~SingleCommand[]} The command object references, or an * empty array if it could not be found. */ this.findAll = function(cmd, msg) { if (typeof cmd !== 'string') return []; if (msg && cmd.startsWith(msg.prefix)) cmd = cmd.replace(msg.prefix, ''); const split = cmd.trim().split(/\s/); const output = []; (function iterate(list, search) { if (!search || search.length == 0) return; const cmd = search[0].toLowerCase(); if (cmd.indexOf('*') < 0) { const single = list.find((el) => { return el.aliases.includes(cmd); }); if (single) { output.push(single); const vals = Object.values(single.subCmds); if (vals.length > 0) iterate(vals, search.slice(1)); return; } } else { const regex = new RegExp(cmd.replace(/\*/g, '.*'), 'g'); list.forEach((el) => { if (el.aliases.find((alias) => { return alias.match(regex); })) { output.push(el); const vals = Object.values(el.subCmds); if (vals.length > 0) iterate(vals, search.slice(1)); } }); } })(Object.values(cmds), split); return output; }; /** * Checks that the given command can be run with the given context. Does not * actually fire the event. * * @public * * @param {?string} cmd The command to validate. Null to use msg to find the * command to validate. * @param {?Discord~Message} msg The message that will fire the event. If * null, checks for channel and guild specific changes will not be * validated. * @param {Command~SingleCommand} [func] A command handler override to use for * settings lookup. If this is not specified, the handler associated with * cmd will be fetched. * @returns {?string} Message causing failure, or null if valid. */ this.validate = function(cmd, msg, func) { if (!func) func = self.find(cmd, msg); if (!func) return 'No Handler'; if (msg && func.options.validOnlyInGuild && !msg.guild) { return 'Guild Only'; } if (msg) { if (msg.guild) { const guildValues = userSettings[msg.guild.id]; if (guildValues) { const commandValues = guildValues[func.getFullName()]; if (commandValues) { const isDisabled = commandValues.isDisabled(msg); if (!isDisabled) return null; const suffix = commandValues.isMuted ? ' Muted' : ''; if (!commandValues.defaultDisabled) { return (isDisabled == 2 ? 'User Disabled Individual' : 'User Disabled') + suffix; } else if (commandValues.permissions) { return 'NoPerm:' + new self.Discord .PermissionsBitField(commandValues.permissions) .toArray() .join(', ') + suffix; } else { return 'User Disabled' + suffix; } } } } const def = func.options.defaultDisabled; const isDisabled = func.options.isDisabled(msg); const bitfield = func.options.permissions; if (!isDisabled) return null; const suffix = func.options.isMuted ? ' Muted' : ''; if (!def) { return (isDisabled == 2 ? 'Disabled Individual' : 'Disabled') + suffix; } else if (bitfield) { return 'NoPerm:' + new self.Discord.PermissionsBitField(bitfield).toArray().join( ', ') + suffix; } else { return 'Disabled' + suffix; } } return null; }; /** * Fetches a list of all currently registered commands. * * @public * * @returns {string[]} Array of all registered commands. */ this.getAllNames = function() { return Object.keys(cmds); }; /** * Allow user to disable a command. * * @private * @type {Command~commandHandler} * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandDisable(msg) { if (!msg.text || !msg.text.trim()) { self.common.reply( msg, 'Please specify a command, and where to disable it.'); return; } const trimmedText = msg.text.replace(self.Discord.MessageMentions.ChannelsPattern, '') .replace(self.Discord.MessageMentions.UsersPattern, '') .replace(self.Discord.MessageMentions.RolesPattern, '') .trim(); const list = self.findAll(trimmedText, msg); if (!list.length) { self.common.reply( msg, 'I was unable to find that command. (`' + trimmedText + '`)'); return; } const settings = []; list.forEach((cmd) => { const name = cmd.getFullName(); if (!userSettings[msg.guild.id]) userSettings[msg.guild.id] = {}; if (!userSettings[msg.guild.id][name]) { userSettings[msg.guild.id][name] = new CommandSetting(cmd.options); userSettings[msg.guild.id][name].myGuild = msg.guild.id; } settings.push(userSettings[msg.guild.id][name]); }); const disabledList = []; msg.mentions.channels.forEach((c) => { settings.forEach((s) => { if (s.disabled.channels[c.id]) return; s.set(s.defaultDisabled ? 'default' : 'disabled', 'channel', c.id); }); disabledList.push(`${c.type} channel: #${c.name}`); }); msg.mentions.members.forEach((m) => { settings.forEach((s) => { if (s.disabled.users[m.id]) return; s.set(s.defaultDisabled ? 'default' : 'disabled', 'user', m.id); }); disabledList.push(`Member: ${m.user.tag}`); }); msg.mentions.roles.forEach((r) => { settings.forEach((s) => { if (s.disabled.roles[`${r.guild.id}/${r.id}`]) return; s.set( s.defaultDisabled ? 'default' : 'disabled', 'role', r.id, r.guild.id); }); disabledList.push(`Role: ${r.name}`); }); trimmedText.split(/\s/).forEach((el) => { const trimmed = el.trim().toLowerCase(); if (trimmed === 'guild' || trimmed === 'everyone' || trimmed === 'all') { settings.forEach((s) => { s.defaultDisabled = true; // s.set('disabled', 'guild', msg.guild.id); }); disabledList.push('Default is now DISABLED'); return; } if (self.Discord.PermissionsBitField.Flags[el]) { settings.forEach((s) => { s.permissions = s.permissions & (~self.Discord.PermissionsBitField.Flags[el]); }); disabledList.push('Permission: ' + el); return; } const role = msg.guild.roles.cache.find((r) => r.name.toLowerCase() == trimmed); if (role) { settings.forEach((s) => { if (s.disabled.roles[role.guild.id + '/' + role.id]) { return; } s.set('disabled', 'role', role.id, role.guild.id); }); disabledList.push('Role: ' + role.name); return; } const user = msg.guild.members.cache.find( (m) => m.user.tag.toLowerCase() == trimmed); if (user) { settings.forEach((s) => { if (s.disabled.user && s.disabled.user[user.id]) return; s.set('disabled', 'user', user.id); }); disabledList.push('Member: ' + user.user.tag); return; } }); const nameList = list.map((el) => '`' + el.getFullName() + '`').join(', '); self.common.reply( msg, 'Disabled\n' + (disabledList.join('\n') || 'Nothing'), 'For ' + nameList); } /** * Allow user to enable a command. * * @private * @type {Command~commandHandler} * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandEnable(msg) { if (!msg.text || !msg.text.trim()) { self.common.reply( msg, 'Please specify a command, and where to enable it.'); return; } const trimmedText = msg.text.replace(self.Discord.MessageMentions.ChannelsPattern, '') .replace(self.Discord.MessageMentions.UsersPattern, '') .replace(self.Discord.MessageMentions.RolesPattern, '') .trim(); const list = self.findAll(trimmedText, msg); if (!list.length) { self.common.reply( msg, 'I was unable to find that command. (`' + trimmedText + '`)'); return; } const settings = []; list.forEach((cmd) => { const name = cmd.getFullName(); if (!userSettings[msg.guild.id]) userSettings[msg.guild.id] = {}; if (!userSettings[msg.guild.id][name]) { userSettings[msg.guild.id][name] = new CommandSetting(cmd.options); userSettings[msg.guild.id][name].myGuild = msg.guild.id; } settings.push(userSettings[msg.guild.id][name]); }); const enabledList = []; msg.mentions.channels.forEach((c) => { settings.forEach((s) => { if (s.enabled.channels[c.id]) return; s.set(s.defaultEnabled ? 'default' : 'enabled', 'channel', c.id); }); enabledList.push(c.type + ' channel: #' + c.name); }); msg.mentions.members.forEach((m) => { settings.forEach((s) => { if (s.enabled.users[m.id]) return; s.set(s.defaultEnabled ? 'default' : 'enabled', 'user', m.id); }); enabledList.push('Member: ' + m.user.tag); }); msg.mentions.roles.forEach((r) => { settings.forEach((s) => { if (s.enabled.roles[r.guild.id + '/' + r.id]) return; s.set( s.defaultEnabled ? 'default' : 'enabled', 'role', r.id, r.guild.id); }); enabledList.push('Role: ' + r.name); }); trimmedText.split(/\s/).forEach((el) => { const trimmed = el.trim().toLowerCase(); if (trimmed === 'guild' || trimmed === 'everyone' || trimmed === 'all') { settings.forEach((s) => { s.defaultDisabled = false; // s.set('enabled', 'guild', msg.guild.id); }); enabledList.push('Default is now ENABLED'); return; } if (self.Discord.Permissions.Flags[el]) { settings.forEach((s) => { s.permissions = s.permissions | self.Discord.Permissions.Flags[el]; }); enabledList.push('Permission: ' + el); return; } const role = msg.guild.roles.cache.find((r) => r.name.toLowerCase() == trimmed); if (role) { settings.forEach((s) => { if (s.enabled.roles[role.guild.id + '/' + role.id]) { return; } s.set('enabled', 'role', role.id, role.guild.id); }); enabledList.push('Role: ' + role.name); return; } const user = msg.guild.members.cache.find( (m) => m.user.tag.toLowerCase() == trimmed); if (user) { settings.forEach((s) => { if (s.enabled.user && s.enabled.user[user.id]) return; s.set('enabled', 'user', user.id); }); enabledList.push('Member: ' + user.user.tag); return; } }); const nameList = list.map((el) => '`' + el.getFullName() + '`').join(', '); self.common.reply( msg, 'Enabled\n' + (enabledList.join('\n') || 'Nothing'), 'For ' + nameList); } /** * Allow user to mute a command. * * @private * @type {Command~commandHandler} * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandBlockCmd(msg) { if (!msg.text || !msg.text.trim()) { self.common.reply( msg, 'Please specify a command.\nThis will suppress errors when a user ' + 'attempts a command when they don\'t have permission to use it.'); return; } const trimmedText = msg.text.trim(); const list = self.findAll(trimmedText, msg); if (!list.length) { self.common.reply( msg, 'I was unable to find that command. (`' + trimmedText + '`)'); return; } const nameList = list.map((cmd) => { const name = cmd.getFullName(); if (!userSettings[msg.guild.id]) userSettings[msg.guild.id] = {}; if (!userSettings[msg.guild.id][name]) { userSettings[msg.guild.id][name] = new CommandSetting(cmd.options); userSettings[msg.guild.id][name].myGuild = msg.guild.id; } userSettings[msg.guild.id][name].isMuted = true; return `\`${name}\``; }); self.common.reply(msg, 'Muted', nameList.join(', ')); } /** * Allow user to unmute a command. * * @private * @type {Command~commandHandler} * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandAllowCmd(msg) { if (!msg.text || !msg.text.trim()) { self.common.reply( msg, 'Please specify a command.\nThis will show errors when a user ' + 'attempts a command when they don\'t have permission to use it.'); return; } const trimmedText = msg.text.trim(); const list = self.findAll(trimmedText, msg); if (!list.length) { self.common.reply( msg, 'I was unable to find that command. (`' + trimmedText + '`)'); return; } const nameList = list.map((cmd) => { const name = cmd.getFullName(); if (!userSettings[msg.guild.id]) userSettings[msg.guild.id] = {}; if (!userSettings[msg.guild.id][name]) { userSettings[msg.guild.id][name] = new CommandSetting(cmd.options); userSettings[msg.guild.id][name].myGuild = msg.guild.id; } userSettings[msg.guild.id][name].isMuted = false; return `\`${name}\``; }); self.common.reply(msg, 'Unmuted', nameList.join(', ')); } /** * Show user the currently configured settings for commands. * * @private * @type {Command~commandHandler} * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandShow(msg) { let commands; if (msg.text && msg.text.trim()) { let text = msg.text.trim().toLowerCase(); if (text.startsWith(msg.prefix)) text = text.replace(msg.prefix, ''); commands = userSettings[msg.guild.id]; if (commands) { const origContent = msg.content; msg.content = text; const cmdObj = self.find(null, msg); msg.content = origContent; if (cmdObj) { commands = commands[cmdObj.getFullName()]; if (!commands) { commands = cmdObj.options; } } else { commands = null; } } if (!commands) { const found = Object.values(cmds).find((el) => { return el.aliases.includes(text); }); if (!found) { if (msg.prefix != self.bot.getPrefix()) { self.common.reply( msg, 'That is not a valid command to lookup.', 'You are using a custom prefix, please include it before the ' + 'command to lookup.'); } else { self.common.reply(msg, 'That is not a valid command to lookup.'); } } else { let output = 'That command is using default settings.\n' + (found.options.defaultDisabled ? 'Disabled' : 'Enabled') + ' by default'; if (found.options.defaultDisabled && found.options.permissions) { output += ' and enabled with the following permissions:\n' + new self.Discord.PermissionsBitField(found.options.permissions) .toArray() .join(', '); } self.common.reply(msg, output); } return; } commands = [[text, commands]]; } else { const defaultValues = Object.values(self.getDefaultSettings()); const defaultEntries = []; (function addSubCmds(vals) { vals.forEach((el) => { defaultEntries.push([el.getFullName(), el.options]); const sCmds = Object.values(el.subCmds); if (sCmds.length > 0) addSubCmds(sCmds); }); })(defaultValues); const defaultOpts = defaultEntries.reduce( (p, c) => { p[c[0]] = c[1]; return p; }, {}); const finalVals = Object.assign(defaultOpts, userSettings[msg.guild.id]); commands = Object.entries(finalVals).filter((el) => { if (el[1].defaultDisabled) { return true; /* return el[1].permissions || el[1].enabled.channels || el[1].enabled.users || el[1].enabled.roles; */ } else { return Object.keys(el[1].disabled.channels).length || Object.keys(el[1].disabled.users).length || Object.keys(el[1].disabled.roles).length; } }); } const output = commands.map((el) => { const tmp = []; let obj; if (el[1].defaultDisabled) { tmp.push('`' + el[0] + (el[1].isMuted ? '~' : '') + '` allowed with:'); if (el[1].permissions) { tmp.push( new self.Discord.PermissionsBitField(el[1].permissions) .toArray() .join(', ')); } obj = el[1].enabled; } else { tmp.push('`' + el[0] + (el[1].isMuted ? '~' : '') + '` blocked for:'); obj = el[1].disabled; } const channels = Object.keys(obj.channels); if (channels.length) { const list = channels.map((c) => { if (!msg.guild.channels.resolve(c)) return ''; return '#' + msg.guild.channels.resolve(c).name; }); tmp.push('Channels: ' + list.join(', ')); } const users = Object.keys(obj.users); if (users.length) { const list = users.map((u) => { if (!msg.guild.members.resolve(u)) return ''; return msg.guild.members.resolve(u).user.tag; }); tmp.push('Members: ' + list.join(', ')); } const roles = Object.keys(obj.roles); if (roles.length) { const list = roles.map((r) => { r = r.split('/')[1]; if (!msg.guild.roles.resolve(r)) return ''; return msg.guild.roles.resolve(r).name; }); tmp.push('Roles: ' + list.join(', ')); } if (tmp.length == 2) return tmp.join(' '); if (tmp.length == 1) tmp.push('Nothing'); return tmp.join('\n'); }).filter((el) => { return el; }); if (output.length > 5800) { self.common.reply(msg, 'Please specify a command to lookup.'); } else { const finalSplits = []; (function splitOutput(num) { const splitLength = Math.ceil(output.length / num); for (let i = 0; i < num; i++) { const section = output.slice(splitLength * i, splitLength * (i + 1)) .join('\n') .length; if (section > 1024) { if (num > 25) return; splitOutput(num + 1); return; } } for (let i = 1; i < num; i++) { finalSplits.push(output.splice(0, splitLength).join('\n')); } finalSplits.push(output.splice(0).join('\n')); })(1); if (finalSplits.length == 0) { self.common.reply( msg, 'I wasn\'t able to fit all settings into a message.'); return; } const embed = new self.Discord.EmbedBuilder(); embed.setColor([255, 0, 255]); embed.setTitle('Command Permissions'); for (let i = 0; i < finalSplits.length; i++) { embed.addFields([{name: '\u200B', value: finalSplits[i]}]); } embed.setDescription( 'Reset values to default with ' + msg.prefix + 'reset\nChange values with ' + msg.prefix + 'enable or ' + msg.prefix + 'disable'); embed.setFooter({text: '~ denotes command is muted on error.'}); msg.channel.send({content: self.common.mention(msg), embeds: [embed]}) .catch(() => { self.common.reply(msg, 'Please specify a command to lookup.') .catch(() => {}); }); } } /** * Reset all custom command settings to default. * * @private * @type {Command~commandHandler} * @fires Command.events#settingsReset * * @param {Discord~Message} msg The message the user sent that triggered this. */ function commandReset(msg) { if (!msg.text || !msg.text.trim()) { self.common .reply( msg, 'Are you sure you wish to reset all' + ' settings for all commands on this server?') .then((msg_) => { msg_.react('✅'); const filter = (reaction, user) => reaction.emoji.name === '✅' && user.id === msg.author.id; msg_.awaitReactions({filter, time: 30000, max: 1}) .then((reactions) => { if (reactions.size === 0) { msg_.edit({content: '`Timed out`'}); return; } msg_.edit({content: '`Confirmed`'}); userSettings[msg.guild.id] = {_updated: true}; self.common.reply( msg, 'All settings for commands have been reset.'); self.fire('settingsReset', msg.guild.id); }); }); } else if (msg.text.indexOf('*') < 0) { msg.content = msg.text; const cmd = self.find(null, msg); if (!cmd || !userSettings[msg.guild.id] || !userSettings[msg.guild.id][cmd.getFullName()]) { self.common.reply( msg, 'That does not appear to be a setting that I can reset for you.', 'It may already be reset.'); return; } self.common .reply( msg, 'Are you sure you wish to reset settings for `' + cmd.getFullName() + '`?') .then((msg_) => { msg_.react('✅'); const filter = (reaction, user) => reaction.emoji.name === '✅' && user.id === msg.author.id; msg_.awaitReactions({filter, time: 30000, max: 1}) .then((reactions) => { if (reactions.size === 0) { msg_.edit({content: '`Timed out`'}); return; } msg_.edit({content: '`Confirmed`'}); delete userSettings[msg.guild.id][cmd.getFullName()]; userSettings[msg.guild.id]._updated = true; self.common.reply( msg, 'Settings for `' + cmd.getFullName() + '` have been reset.'); self.fire('settingsReset', msg.guild.id, cmd.getFullName()); }); }); } else { const cmd = self.findAll(msg.text, msg); if (cmd.length == 0) { self.common.reply( msg, 'I couldn\'t find any commands to reset that match what' + ' you asked for.'); return; } const nameList = cmd.map((el) => '`' + el.getFullName() + '`').join(', '); self.common .reply( msg, 'Are you sure you wish to reset settings for all of the ' + 'following commands?', nameList) .then((msg_) => { msg_.react('✅'); const filter = (reaction, user) => reaction.emoji.name === '✅' && user.id === msg.author.id; msg_.awaitReactions({filter, time: 30000, max: 1}) .then((reactions) => { if (reactions.size === 0) { msg_.edit({content: '`Timed out`'}); return; } msg_.edit({content: '`Confirmed`'}); cmd.forEach((el) => { delete userSettings[msg.guild.id][el.getFullName()]; userSettings[msg.guild.id]._updated = true; self.fire('settingsReset', msg.guild.id, el.getFullName()); }); self.common.reply( msg, 'Settings for have been reset.', nameList); }); }); } } /** * Register an event listener. * * @public * * @param {string} name The name of the event to listen for. * @param {Function} handler The function to call when the event is fired. */ this.addEventListener = function(name, handler) { if (!eventList[name]) eventList[name] = []; eventList[name].push(handler); }; /** * Remove an event listener. * * @public * * @param {string} name The name of the event to listen for. * @param {Function} handler The handler that is currently registered to * listen on this event. */ this.removeEventListener = function(name, handler) { const handlers = eventList[name]; if (!handlers) return; const index = handlers.findIndex((el) => { return el == handler; }); if (index < 0) return; handlers.splice(index, 0); }; /** * Fire all handlers listening for an event. * * @public * * @param {string} name The name of the event to fire. * @param {*} args The arguments to pass to the handlers. */ this.fire = function(name, ...args) { const handlers = eventList[name]; if (!handlers || handlers.length == 0) return; handlers.forEach((h) => h.apply(h, args)); }; /** * Escape a given string to be passed into a regular expression. * * @private * @param {string} str Input to escape. * @returns {string} Escaped string. */ function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } } module.exports = new Command();