// Copyright 2019-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const SubModule = require('./subModule.js'); /** * @description Manages raid blocking commands and configuration. * @augments SubModule * @listens Discord~Client#guildMemberAdd * @listens Command#raid * @listens Command#lockdown * @fires RaidBlock#shutdown * @fires RaidBlock#lockdown * @fires RaidBlock#action */ class RaidBlock extends SubModule { /** * @description SubModule managing echo related commands. */ constructor() { super(); /** @inheritdoc */ this.myName = 'RaidBlock'; /** * Guild settings for raids mapped by their guild id. * * @private * @type {object.<RaidBlock~RaidSettings>} * @default */ this._settings = {}; /** * All event handlers registered. * * @private * @type {object.<Array.<Function>>} * @default */ this._events = {}; this.save = this.save.bind(this); this.on = this.on.bind(this); this.removeListener = this.removeListener.bind(this); this._commandLockdown = this._commandLockdown.bind(this); this._onGuildMemberAdd = this._onGuildMemberAdd.bind(this); } /** @inheritdoc */ initialize() { this.command.on(new this.command.SingleCommand( ['lockdown', 'raid'], this._commandLockdown, { validOnlyInGuild: true, defaultDisabled: true, permissions: this.Discord.PermissionsBitField.Flags.ManageRoles | this.Discord.PermissionsBitField.Flags.ManageGuild | this.Discord.PermissionsBitField.Flags.BanMembers, })); this.client.on('guildMemberAdd', this._onGuildMemberAdd); this.client.guilds.cache.forEach((g) => { this.common.readAndParse( `${this.common.guildSaveDir}${g.id}/raidSettings.json`, (err, parsed) => { if (err) return; this._settings[g.id] = RaidSettings.from(parsed); }); }); } /** @inheritdoc */ shutdown() { this.command.removeListener('lockdown'); this.client.removeListener('guildMemberAdd', this._onGuildMemberAdd); this._fire('shutdown'); } /** @inheritdoc */ save(opt) { if (!this.initialized) return; Object.entries(this._settings).forEach((obj) => { if (!obj[1]._updated) return; obj[1]._updated = false; const dir = `${this.common.guildSaveDir}${obj[0]}/`; const filename = `${dir}raidSettings.json`; if (opt == 'async') { this.common.mkAndWrite(filename, dir, JSON.stringify(obj[1])); } else { this.common.mkAndWriteSync(filename, dir, JSON.stringify(obj[1])); } }); } /** * @description Send a message to a guild's moderation channel (if * configured), describing the action that took place. * @see {@link ModLog} * * @private * @param {*} args The arguments to pass to ModLog. */ _modLog(...args) { const modLog = this.bot.getSubmodule('./modLog.js'); if (!modLog) return; modLog.output(...args); } /** * @description Mute a discord guild member. * @see {@link Moderation~muteMember} * * @private * @param {Discord~GuildMember} member Member to mute. * @param {Function} cb Callback function. */ _muteMember(member, cb) { const moderation = this.bot.getSubmodule('./moderation.js'); if (!moderation) return; moderation.muteMember(member, cb); } /** * @description Handle a member being added to a guild. * * @private * @param {Discord~GuildMember} member The guild member that was * added to a guild. */ _onGuildMemberAdd(member) { if (!this._settings[member.guild.id]) return; const s = this._settings[member.guild.id]; const now = Date.now(); while (s.history.length > 0 && now - s.history[0].time > s.timeInterval) { s.history.splice(0, 1); } for (let i = 0; i < s.history.length; i++) { if (s.history[i].id == member.id) { s.history.splice(i, 1); break; } } s.history.push({time: now, id: member.id}); if (s.enabled) { if (s.numJoin <= s.history.length) { this._fire('lockdown', {id: member.guild.id, settings: s}); if (now - s.start >= s.duration) { this._modLog( member.guild, 'lockdown', null, null, 'Lockdown Activated Automatically'); for (let i = 0; s.history[i].time < now; i++) { const m = member.guild.members.resolve(s.history[i].id); if (m) this._doAction(m, s); } this.debug( 'Started lockdown automaticaly: ' + member.guild.id + ' (' + s.history.length + ' in ' + s.timeInterval + ' for ' + s.duration); } s.start = now; } if (now - s.start < s.duration) { this._doAction(member, s); } } } /** * @description Perform lockdown action on a member with given settings. * @private * @param {Discord~GuildMember} member Member to perform action on. * @param {RaidBlock~RaidSettings} s Guild settings for raids. */ _doAction(member, s) { this._fire( 'action', {id: member.guild.id, action: s.action, user: member.user}); const self = this; const go = function() { switch (s.action) { case 'kick': member.kick('Server on raid lockdown.') .then((m) => { self._modLog(m.guild, s.action, m.user, null, 'Raid Lockdown'); }) .catch((err) => { self.error('Failed to kick user during raid!'); console.error(err); }); break; case 'ban': member.ban({reason: 'Server on raid lockdown.'}) .then((m) => { self._modLog(m.guild, s.action, m.user, null, 'Raid Lockdown'); }) .catch((err) => { self.error('Failed to kick user during raid!'); console.error(err); }); break; case 'mute': self._muteMember(member, (err) => { if (err) { self._modLog( member.guild, s.action, member.user, null, 'Failed to mute: ' + err); } else { self._modLog( member.guild, s.action, member.user, null, 'Raid Lockdown'); } }); break; } }; if (s.sendWarning) { let verb = ''; switch (s.action) { case 'kick': verb = 'kicked'; break; case 'ban': verb = 'banned'; break; case 'mute': verb = 'muted'; break; } const finalMessage = s.warnMessage.replace(/\{action\}/, verb) .replace(/\{server\}/g, member.guild.name) .replace(/\{username\}/g, member.user.username); member.send({content: finalMessage}).then(go).catch(go); } else { go(); } } /** * @description Initiate a server lockdown, or lift a current lockdown. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#lockdown * @listens Command#raid */ _commandLockdown(msg) { const s = this.getSettings(msg.guild.id); const now = Date.now(); if (msg.text.trim().length == 0) { if (!s.enabled) { this.common.reply(msg, 'Lockdown Status', 'Not Configured'); return; } const finalString = []; const active = now - s.start < s.duration; finalString.push(`Active: ${active}`); if (active) { const dateString = new Date(s.start).toString(); const timeSince = this._formatDelay(now - s.start); const timeLeft = this._formatDelay(1 * s.start + 1 * s.duration); const durationString = this._formatDelay(s.duration); finalString.push(`Since: ${dateString} (${timeSince})`); finalString.push(`Duration: ${durationString} (${timeLeft})`); finalString.push(`Action: ${s.action}`); } else { const dateString = s.start ? new Date(s.start).toString() : 'Never'; const timeSince = s.start ? `(${this._formatDelay(now - s.start)} ago})` : ''; const timeLeft = s.start ? `${this._formatDelay(now - (s.start + s.duration))} ago` : ''; const durationString = this._formatDelay(s.duration); const intervalString = this._formatDelay(s.timeInterval); finalString.push(`Previous: ${dateString} ${timeSince}`); finalString.push(`Ended: ${timeLeft}`); finalString.push( `Activates if ${s.numJoin} join within ${intervalString}.`); finalString.push(`Duration: ${durationString}`); finalString.push(`Action: ${s.action}`); } this.common.reply(msg, 'Lockdown Status', finalString.join('\n')); return; } const cmd = msg.text.trim().split(' ')[0]; const enableCmds = [ 'enable', 'enabled', 'start', 'begin', 'on', 'active', 'activate', 'protect', ]; const disableCmds = [ 'disable', 'end', 'off', 'finish', 'deactivate', 'inactive', 'disabled', 'cancel', 'abort', 'stop', ]; if (enableCmds.includes(cmd)) { s.enabled = true; s.start = now; this._fire('lockdown', {id: msg.guild.id, settings: s}); this.common.reply(msg, 'Activated Lockdown'); this._modLog( msg.guild, 'lockdown', null, msg.author, 'Lockdown Activated Manually'); } else if (disableCmds.includes(cmd)) { if (s.enabled && now - s.start < s.duration) { s.start = null; this.common.reply(msg, 'Deactivated Lockdown'); this._modLog( msg.guild, 'lockdown', null, msg.author, 'Lockdown Deactivated Manually'); } else { this.common.reply(msg, 'Lockdown is already deactivated'); } } else { this.common.reply( msg, 'Oops! I don\'t understand that.', 'https://www.spikeybot.com/control/ has most settings for this.'); } } /** * @description Get the settings for a guilds. * @public * @param {string} gId The ID of the guild to fetch. * @returns {RaidBlock~RaidSettings} Reference to settings object. If it does * not exist yet, it will first be created with defaults. */ getSettings(gId) { if (!this._settings[gId]) this._settings[gId] = new RaidSettings(); return this._settings[gId]; } /** * @description Register an event handler for a specific event. Fires the * handler when the event occurs. * @public * @param {string} event Name of the event to listen for. * @param {Function} handler Callback function handler to fire on the event. */ on(event, handler) { if (!this._events[event]) this._events[event] = []; this._events[event].push(handler); } /** * @description Remove an event handler that was previously registered. * @public * @param {string} event Name of the event to listen for. * @param {Function} handler Callback function handler to fire on the event. */ removeListener(event, handler) { if (!this._events[event]) return; if (!handler) return; const index = this._events[event].findIndex((el) => el === handler); if (index < 0) return; this._events[event].splice(index, 1); } /** * @description Fire an event on all handlers. * @private * @param {string} event The event name to fire. * @param {*} args The arguments to pass to handlers. */ _fire(event, ...args) { if (!this._events[event]) return; this._events[event].forEach((el) => el(...args)); } /** * Format a duration in milliseconds into a human readable string. * * @private * @param {number} msecs Duration in milliseconds. * @returns {string} Formatted string. */ _formatDelay(msecs) { let output = ''; let unit = 7 * 24 * 60 * 60 * 1000; if (msecs >= unit) { const num = Math.floor(msecs / unit); output += num + ' week' + (num == 1 ? '' : 's') + ', '; msecs -= num * unit; } unit /= 7; if (msecs >= unit) { const num = Math.floor(msecs / unit); output += num + ' day' + (num == 1 ? '' : 's') + ', '; msecs -= num * unit; } unit /= 24; if (msecs >= unit) { const num = Math.floor(msecs / unit); output += num + ' hour' + (num == 1 ? '' : 's') + ', '; msecs -= num * unit; } unit /= 60; if (msecs >= unit) { const num = Math.floor(msecs / unit); output += num + ' minute' + (num == 1 ? '' : 's') + ', '; msecs -= num * unit; } unit /= 60; if (msecs >= unit) { const num = Math.round(msecs / unit); output += num + ' second' + (num == 1 ? '' : 's') + ''; } return output.replace(/,\s$/, ''); } } /** * Container for RaidBlock related settings. * * @memberof RaidBlock * @inner */ class RaidSettings { /** * @description Create a settings object. * * @param {boolean} [enabled=false] Is raid protection enabled. * @param {number} [numJoin=5] Number of users joined in given time. * @param {number} [timeInterval=10000] Time interval for checking number of * users joined. * @param {number} [duration=600000] Amount of time to be in automated * lockdown. * @param {string} [action='kick'] Action to perform during lockdown. * @param {?string} [warnMessage=null] DM message to send. * @param {boolean} [sendWarning=false] Should send DM. */ constructor( enabled = false, numJoin = 5, timeInterval = 10000, duration = 600000, action = 'kick', warnMessage = null, sendWarning = false) { /** * @description Is raid protection enabled. * @public * @type {boolean} * @default false */ this.enabled = enabled; /** * @description Number of users joined within the configured time interval * to be considered a raid. * @public * @type {number} * @default 5 */ this.numJoin = numJoin; /** * @description Amount of time for if too many players join, it will be * considered a raid. Time in milliseconds. * @public * @type {number} * @default 10000 */ this.timeInterval = timeInterval; /** * @description Amount of time to stay on lockdown after a raid has been * detected to have ended. * @public * @type {number} * @default 600000 (10 Minutes) */ this.duration = duration; /** * @description Action to perform, while on lockdown, to new member who * join. Possible values are `kick`, `ban`, or `mute`. * @public * @type {string} * @default 'kick' */ this.action = action; if (!['kick', 'ban', 'mute'].includes(this.action)) this.action = 'kick'; /** * @description Current raid block state information. Not null is if server * has had a lockdown, start is the last timestamp we consider the raid to * be active, or null if no raid is active. * @public * @type {?number} * @default */ this.start = null; /** * @description History of previous member who joined the server within the * time interval. Time is timestamp of join, and id is user's account id. * @public * @type {Array.<{time: number, id: string}>} * @default */ this.history = []; /** * @description Message to send to users when they are being warned that the * raid lockdown is active. * @public * @type {string} * @default */ this.warnMessage = warnMessage || '{username}, you have been {action} in' + ' {server} because the server is on lockdown.'; /** * @description Should we additionally send `warnMessage` in a DM to the * user prior to performing the action during a lockdown. * @public * @type {boolean} * @default */ this.sendWarning = sendWarning; /** * @description Was this modified since our last save. * @private */ this._updated = false; } /** * @description Queue this to be saved to disk. * @public */ updated() { this._updated = true; } } /** * @description Create a RaidSettings object from a similarly structured object. * Similar to copy-constructor. * * @public * @static * @param {object} obj Object to convert to RaidSettings. * @returns {RaidBlock~RaidSettings} Created raidsettings object. */ RaidSettings.from = function(obj) { if (!obj) return new RaidSettings(); const output = new RaidSettings( obj.enabled, obj.numJoin, obj.timeInterval, obj.duration, obj.action, obj.warnMessage, obj.sendWarning); return output; }; RaidBlock.RaidSettings = RaidSettings; module.exports = new RaidBlock();