const { stripIndents } = require('common-tags'); // eslint-disable-next-line no-unused-vars const { Message } = require('discord.js'); const { Collection, Util, Emojis } = require('../../util/'); const { User } = require('../extensions/'); const { Warn, Unmute, Mute, Kick, Softban, Unban, Ban } = require('./infractions/'); // eslint-disable-next-line no-unused-vars const Infraction = require('./interfaces/Infraction'); const Constants = { MaxTargets: 10, //10+(10*premium-tier), theoretical max = 40 Infractions: { WARN: Warn, UNMUTE: Unmute, MUTE: Mute, KICK: Kick, SOFTBAN: Softban, UNBAN: Unban, BAN: Ban }, Opposites: { MUTE: "UNMUTE", BAN: "UNBAN" }, Hierarchy: { WARN: 0, MUTE: 1, KICK: 2, SOFTBAN: 3, BAN: 4 } }; class ModerationManager { constructor(client) { this.client = client; this.callbacks = new Collection(); this._permissionCheck = null; } async initialize() { //TODO: Load infractions for non-cached guilds... this.client.storageManager.mongodb.infractions.find({ duration: { $gt: 0 }, guild: { $in: this.client.guilds.cache.keyArray() }, _callbacked: false }).then((results) => { this.client.logger.info(`Filtering ${results.length} infractions for callback.`); this._handleExpirations(results); }); } /** * * * @param {Infraction} Infraction * @param {Message} message * @param {object} { targets, reason, duration, data } * @return {object|Message} The successfully moderated targets of the response message for failure * @memberof ModerationManager */ async handleInfraction(Infraction, message, { targets, reason, duration, data }) { const maxTargets = Constants.MaxTargets + message.guild.premium*Constants.MaxTargets; if(targets.length > maxTargets) { return message.respond(stripIndents`${message.format('MODERATIONMANAGER_INFRACTION_MAXTARGETS', { maxTargets, type: Infraction.targetType })} ${maxTargets < 40 ? message.format('MODERATIONMANAGER_INFRACTION_MAXTARGETSALT') : ''}`, { emoji: 'failure' }); } const silent = Boolean(message.guild._settings.silent || message.arguments.silent); const { autoModeration, moderationPoints } = message.guild._settings; let points = 0, expiration = 0; if(moderationPoints.enabled) { // Default point and expiration are handled in arguments if(message.arguments.points) { points = parseInt(message.arguments.points.value); } if(message.arguments.expiration) { expiration = parseInt(message.arguments.expiration.value); } } const force = message.arguments.force && message.arguments.force.value; let original = null; const responses = []; for (const target of targets) { const infraction = new Infraction(this.client, { //this will not save to db unless executed dw, only utilizes the constructor. executor: message.member, guild: message.guild, channel: message.channel, arguments: message.arguments, message, target, reason, duration, silent, data, points, expiration }); if(!original) original = infraction.type; let verification = infraction.verify(message.member, target, message.channel); if(verification instanceof Promise) verification = await verification; if(verification.error) { responses.push(verification); continue; } if(infraction.targetType === 'USER') { const userTarget = target instanceof User ? target : target.user; const oldPoints = await userTarget.totalPoints(message.guild); const newPoints = oldPoints + points; if(autoModeration.enabled && points > 0 && !force) { let result = null; for(let [threshold, action] of Object.entries(autoModeration.thresholds)) { //eslint-disable-line prefer-const threshold = parseInt(threshold); if(oldPoints >= threshold) continue; //Should have already executed the threshold. if(newPoints >= threshold) { result = { threshold, ...action }; } } if(result && Constants.Hierarchy[infraction.type] <= Constants.Hierarchy[result.type]) { const escalation = new Constants.Infractions[result.type](this.client, { executor: message.member, guild: message.guild, channel: message.channel, reason: stripIndents`${reason} *${message.format('INFRACTION_AUTOMODESCALATION')}*`, message, target, duration: result.length, silent, data, points, expiration }); let verification = escalation.verify(message.member, target, message.channel); if(verification instanceof Promise) verification = await verification; if(verification.error) { responses.push(verification); continue; } escalation.totalPoints = await userTarget.totalPoints(message.guild, { points, expiration, timestamp: infraction.timestamp }); responses.push(await escalation.execute()); continue; } else { infraction.totalPoints = await userTarget.totalPoints(message.guild, { points, expiration, timestamp: infraction.timestamp }); } } } responses.push(await infraction.execute()); } const success = Boolean(responses.some((r) => !r.error)); const succeeded = responses.filter((r) => !r.error); const failed = responses.filter((r) => r.error); const fatals = failed.filter((f) => f.fatal); if(fatals.length > 0) { const [ error ] = fatals; return message.respond(message.format(error.reason), { emoji: 'failure' }); } const successes = {}; const fails = {}; for(const response of responses) { const { type, target } = response.infraction; if(!response.error) { if(!silent) { if(successes[type]) { successes[type].targets.push(target.display); } else { successes[type] = { targets: [target.display], infraction: response.infraction }; } } } else { if(fails[type]) { fails[type].targets.push(target.display); } else { fails[type] = { targets: [target.display], infraction: response.infraction, reason: response.reason }; } } } const succeededTargets = succeeded.map((s) => s.infraction.target); const actions = await this._handleArguments(message, succeededTargets); //eslint-disable-line no-unused-vars let string = ""; for(const [ type, data ] of Object.entries(successes)) { if(silent) continue; const { dictionary, targetType } = data.infraction; const reason = type === original ? Util.escapeMarkdown(data.infraction.reason) : message.format('INFRACTION_ESCALATIONREASON'); const str = `${Emojis.success} ${message.format('INFRACTION_SUCCESS', { infraction: dictionary.past, targetType: `${targetType.toLowerCase()}${data.targets.length === 1 ? '' : 's'}`, target: data.targets.map((t) => `**${Util.escapeMarkdown(t)}**`).join(' '), text: type === original ? ` ${reason.length > 120 ? `for: \`${reason.substring(0, 117)}...\`` : `for: \`${reason}\``}` : ` because \`${reason}\`` })}`; type === original //eslint-disable-line ? string = `${str}\n${string}` : string += str; } for(const [ type, data ] of Object.entries(fails)) { const { dictionary, targetType } = data.infraction; const str = `${Emojis.failure} ${message.format('INFRACTION_FAIL', { infraction: dictionary.present, targetType: `${targetType.toLowerCase()}${data.targets.length === 1 ? '' : 's'}`, target: data.targets.map((t) => `**${Util.escapeMarkdown(t)}**`).join(' '), reason: message.format(data.reason) })}`; (type === original && !success) //eslint-disable-line ? string = `${str}\n${string}` : string += str; } if(success && silent) { //Delete message if silent. try { await message.delete(); } catch(e) {} //eslint-disable-line no-empty } if(string) message.respond(string); return succeeded; } // Similar to handleInfraction, but meant for automod, deals with only 1 target async autoModerate({ guild, action, message, reason, silent, data }) { const { autoModeration, moderationPoints } = guild._settings; let points = 0, expiration = 0, response = null; const { force } = action; const Infraction = Constants.Infractions[action.type]; if (moderationPoints.enabled) { points = action.points || moderationPoints.points[action.type]; expiration = action.expiration || moderationPoints.expirations[action.type]; } const infractionData = { executor: guild.me, guild, channel: message.channel, message, target: message.member, reason, duration: action.duration, silent, data, points, expiration }; const infraction = new Infraction(this.client, infractionData); const verification = await infraction.verify(); if (verification.error) { this.client.logger.debug(`Automod infraction failed verification.\n${infraction.type}:\n${JSON.stringify(infractionData)}`); return; // Possibly add a automod error log channel setting and do something here -- skip for now } if (autoModeration.enabled && points && !force) { const userTarget = message.author; const oldPoints = await userTarget.totalPoints(guild); const newPoints = oldPoints + points; let result = null; // eslint-disable-next-line prefer-const for (let [threshold, action] of Object.entries(autoModeration.thresholds)) { threshold = parseInt(threshold); if (oldPoints >= threshold) continue; if (newPoints >= threshold) result = { threshold, ...action }; } if (result && Constants.Hierarchy[infraction.type] <= Constants.Hierarchy[result.type]) { infractionData.duration = result.length; infractionData.reason += `\n*${message.format('INFRACTION_AUTOMODESCALATION')}*`; const escalation = new Constants.Infractions[result.type](this.client, infractionData); const verification = await escalation.verify(); if (verification.error) { this.client.logger.debug(`Automod escalated infraction failed verification.\n${infraction.type}:\n${JSON.stringify(infractionData)}`); return; // Possibly add a automod error log channel setting and do something here -- skip for now } //: moderationPoints.expirations[escalation.type] escalation.totalPoints = await userTarget.totalPoints(guild, { points, expiration, timestamp: infraction.timestamp }); response = await escalation.execute(); } else { infraction.totalPoints = await userTarget.totalPoints(guild, { points, expiration, timestamp: infraction.timestamp }); } } if (!response) response = await infraction.execute(); if (response.error) this.client.logger.debug(`Automod infraction execution failed:\n${JSON.stringify(response)}`); // TODO: // Figure out what we want to do with these errors if anything apart from potentially log them // Most errors here would probably be permission issues/edge cases caused by server configuration/discord perms } async _handleArguments(message, targets) { const actions = { prune: async (message, argument, targets) => { const users = targets.map((t) => t.id); let messages = await message.channel.messages.fetch({ limit: argument.value }); messages = messages.filter((m) => { return users.includes(m.author.id) && m.deletable; }); try { await message.channel.bulkDelete(messages, true); } catch(err) {} //eslint-disable-line no-empty return messages.size; } }; const responses = {}; for(const arg of Object.values(message.arguments)) { // console.log(arg, targets); if(actions[arg.name]) { let action = actions[arg.name](message, arg, targets); if(action instanceof Promise) action = await action; responses[arg.name] = action; } } return responses; } async _handleExpirations(infractions = []) { const currentDate = Date.now(); const resolve = async (i) => { this.client.logger.debug("Resolving infraction"); const undoClass = Constants.Infractions[Constants.Opposites[i.type]]; if(!undoClass) return false; const guild = this.client.guilds.resolve(i.guild); await guild.settings(); //just incase let target = null; if(i.targetType === 'USER') { target = await guild.members.resolve(i.target); if(!target) { try { target = await guild.members.fetch(i.target); } catch(e) {} //eslint-disable-line no-empty if(!target && i.type === 'BAN') { try { target = await this.client.users.fetch(i.target); } catch(e) {} //eslint-disable-line no-empty } } } else if(i.targetType === 'CHANNEL') { target = guild.channels.resolve(i.target); } if(!target) { this.client.logger.debug(`User left the guild or channel was deleted? Unable to resolve target.\n${i}`); return false; } const executor = guild.members.resolve(i.executor) || guild.me; await new undoClass(this.client, { reason: `AUTO-${Constants.Opposites[i.type]} from Case ${i.case}`, channel: guild.channels.resolve(i.channel), hyperlink: i.modLogMessage && i.modLogChannel ? `https://discord.com/channels/${i.guild}/${i.modLogChannel}/${i.modLogMessage}` : null, data: i.data, guild, target, executor }).execute(); //TODO: Log this, should never error... hopefully. await this.client.storageManager.mongodb.infractions.updateOne( { _id: i._id }, { _callbacked: true } ).catch((e) => {}); //eslint-disable-line no-empty, no-unused-vars, no-empty-function return true; }; for(const infraction of infractions) { const expiration = infraction.timestamp + infraction.duration*1000; if(expiration-currentDate <= 0) { await resolve(infraction); continue; } this.client.logger.debug(`Going to resolve infraction in: ${expiration-currentDate}`); this.callbacks.set(infraction.id, { timeout: setTimeout(async () => { await resolve(infraction); }, expiration-currentDate), infraction }); } } async _removeExpiration(expiration) { this.client.logger.debug(`Expired infraction ${expiration.infraction.type} for user ${expiration.infraction.target}.`); await this.client.storageManager.mongodb.infractions.updateOne( { id: expiration.infraction.id }, { _callbacked: true } ).catch((e) => {}); //eslint-disable-line no-empty, no-unused-vars, no-empty-function clearTimeout(expiration.timeout); //just incase node.js is doing some bullshit this.callbacks.delete(expiration.infraction.id); } } module.exports = ModerationManager;