// 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();