// Copyright 2019-2022 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const fs = require('fs'); const SubModule = require('./subModule.js'); /** * @description Provides interface to allow other bots to run commands. * @augments SubModule * @listens Discord~Client#message * @listens Command#togglebot */ class BotCommands extends SubModule { /** * @description SubModule providing external bot command interface. */ constructor() { super(); /** @inheritdoc */ this.myName = 'BotCommands'; /** * @description The name of the file to check for if bot commands are * enabled. * @private * @constant * @default * @type {string} */ this._filename = '/enableBotCommands'; /** * @description Maximum number of commands that can be triggered this way * per {@link BotCommands~_maxDelta}. * @private * @type {number} * @constant * @default */ this._maxNum = 5; /** * @description Amount of time in milliseconds where a maximum of * {@link BotCommands~_maxNum} commands may be triggered before cooldown is * started. * @private * @type {number} * @constant * @default */ this._maxDelta = 15 * 1000; /** * @description Amount of time in milliseconds to ignore all bot commands * after exceeding the rate limit. * @private * @type {number} * @constant * @default */ this._cooldownLength = 10 * 1000; /** * @description History of last few commands triggered per-guild. Used to * put command triggering on cooldown if rate limits are exceeded. * @private * @type {object.<{ * history: Array.<{author: string, time: number}>, * cooldownStart: number * }>} * @default */ this._recentCommands = {}; this._onMessage = this._onMessage.bind(this); this._commandToggleBotCmds = this._commandToggleBotCmds.bind(this); } /** @inheritdoc */ initialize() { this.command.on(new this.command.SingleCommand( [ 'allowbot', 'allowbots', 'enablebot', 'enablebots', 'togglebot', 'togglebots', 'denybot', 'denybots', 'disablebot', 'disablebots', ], this._commandToggleBotCmds, new this.command.CommandSetting({ validOnlyInGuild: true, defaultDisabled: true, permissions: this.Discord.PermissionsBitField.Flags.ManageRoles | this.Discord.PermissionsBitField.Flags.ManageGuild, }))); this.client.guilds.cache.forEach((g) => { this.common.readFile( `${this.common.guildSaveDir}${g.id}${this._filename}`, () => {}); }); this.client.on('messageCreate', this._onMessage); } /** @inheritdoc */ shutdown() { this.command.deleteEvent('allowbot'); this.client.removeListener('messageCreate', this._onMessage); } /** * Handle messages sent by bots. * * @private * @param {Discord~Message} msg Message was sent. * @listens Discord#message */ _onMessage(msg) { if (!msg.guild || !msg.author.bot || msg.author.id === this.client.user.id) { return; } const prefixRegex = new RegExp(`^<@!?${this.client.user.id}>`); if (!msg.content.match(prefixRegex)) return; let recent = this._recentCommands[msg.guild.id]; const now = Date.now(); if (recent && now - recent.cooldownStart < this._cooldownLength) return; if (!fs.existsSync( `${this.common.guildSaveDir}${msg.guild.id}${this._filename}`)) { return; } msg.content = msg.content.replace(prefixRegex, '').trim(); msg.prefix = this.bot.getPrefix(msg.guild); msg.botCmd = true; const commandSuccess = this.command.validate(msg.content.split(/ |\n/)[0], msg); if (commandSuccess !== 'No Handler') { if (!recent) { recent = this._recentCommands[msg.guild.id] = { history: [], cooldownStart: 0, }; } const hist = recent.history; hist.push({author: msg.author.id, time: now}); let num = 0; const oldest = now - this._maxDelta; while (num < hist.length && hist[num].time < oldest) num++; if (num > 0) hist.splice(0, num); if (hist.length > this._maxNum) { this.common.reply( msg, 'Bot Command Rate Limit', 'Rate limit exceeded. All bot commands will be ignored for ' + '10 seconds.'); this._recentCommands[msg.guild.id].cooldownStart = now; return; } } const content = msg.content.replace(/\n/g, '\\n'); let author; let logged; if (msg.guild !== null) { author = `Bot:${msg.guild.id}#${msg.channel.id}@${msg.author.id}`; } else { author = `Bot:PM:${msg.author.id}@${msg.author.tag}`; } if (!commandSuccess) { logged = `${author} ${content}`; this.log(logged); } else { logged = `${author} ${commandSuccess} ${content}`; this.debug(logged); } const start = Date.now(); this.command.trigger(msg); const delta = Date.now() - start; if (delta > 20) { this.debug(`${logged} took an excessive ${delta}ms`); } } /** * @description Toggle whether bots are able to run commands on this guild. * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#togglebot */ _commandToggleBotCmds(msg) { const enableList = ['enable', 'on', 'allow', 'true']; const disableList = ['disable', 'off', 'deny', 'false', 'disallow']; const file = `${this.common.guildSaveDir}${msg.guild.id}${this._filename}`; const exists = fs.existsSync(file); let setTo = true; const content = msg.content.toLocaleLowerCase(); if (enableList.find((el) => content.indexOf(el) > -1)) { setTo = true; } else if (disableList.find((el) => content.indexOf(el) > -1)) { setTo = false; } else { setTo = !exists; } if (!exists && setTo) { const emoji = '✅'; this.common .reply( msg, 'Are you sure?', 'Allowing bots to run commands could potentially cause a ' + 'feedback loop. Only enable this if you know what you are' + ' doing. Use at your own risk.\nReact with ' + emoji + ' to confirm') .then((msg_) => { msg_.react(emoji).catch(() => {}); const filter = (reaction, user) => reaction.emoji.name == emoji && user.id === msg.author.id; msg_.awaitReactions({filter, max: 1, time: 30 * 1000}) .then((reactions) => { msg_.reactions.removeAll().catch(() => {}); if (reactions.size == 0) { msg_.edit({content: 'Timed Out'}).catch(() => {}); return; } try { this.common.mkAndWriteSync(file, null, 'true'); this.common.reply(msg, 'Bot Commands', 'Now Allowed'); } catch (err) { this.error( 'Failed to enable bot commands: ' + msg.guild.id); console.error(err); this.common.reply( msg, 'Bot Commands', 'Failed to toggle due to internal error.'); } }); }); } else if (exists && !setTo) { this.common.unlink(file, (err) => { if (err) { this.error(`Failed to disable bot commands: ${msg.guild.id}`); console.error(err); this.common.reply( msg, 'Bot Commands', 'Failed to toggle due to internal error.'); } else { this.common.reply(msg, 'Bot Commands', 'Now Disallowed'); } }); } else { this.common.reply( msg, 'Bot Commands', setTo ? 'Already allowed' : 'Already disallowed'); } } } module.exports = new BotCommands();