From 62d2fe48abd1294bbded8f0071a4a2e269c38dbf Mon Sep 17 00:00:00 2001 From: "Navy.gif" Date: Tue, 15 Mar 2022 02:01:58 +0200 Subject: [PATCH] moderation manager --- src/structure/client/ModerationManager.js | 466 ++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 src/structure/client/ModerationManager.js diff --git a/src/structure/client/ModerationManager.js b/src/structure/client/ModerationManager.js new file mode 100644 index 0000000..18245dd --- /dev/null +++ b/src/structure/client/ModerationManager.js @@ -0,0 +1,466 @@ +const { stripIndents } = require('common-tags'); +const { User } = require('discord.js'); +const { Collection } = require('@discordjs/collection'); + +const { Emojis, Constants } = require('../../constants'); +const Util = require('../../Util.js'); +const { Warn, Unmute, Mute, Kick, Softban, Unban, Ban, Addrole, Removerole, Lockdown, Unlockdown } = require('../components/infractions'); +const Logger = require('./Logger'); +const Constant = { + MaxTargets: 10, //10+(10*premium-tier), theoretical max = 40 + Infractions: { + WARN: Warn, + UNMUTE: Unmute, + MUTE: Mute, + KICK: Kick, + SOFTBAN: Softban, + UNBAN: Unban, + BAN: Ban, + ADDROLE: Addrole, + REMOVEROLE: Removerole, + LOCKDOWN: Lockdown, + UNLOCKDOWN: Unlockdown + }, + Hierarchy: { + WARN: 0, + MUTE: 1, + KICK: 2, + SOFTBAN: 3, + BAN: 4 + } +}; + +class ModerationManager { + + constructor(client) { + + this.client = client; + this.callbacks = new Collection(); + this.logger = new Logger(this); + + } + + 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.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, info) { + + const { targets } = info; + + const maxTargets = Constant.MaxTargets + message.guild.premium * Constant.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 force = message.arguments?.force?.value || false; + + const responses = []; + for (const target of targets) { + const response = await this._handleTarget(Infraction, target, { + message, + guild: message.guild, + channel: message.channel, + executor: message.member, + arguments: message.arguments, + points: message.arguments?.points?.value, + expiration: message.arguments?.expiration?.value, + reason: info.reason, + duration: info.duration, + data: info.data, + force, + silent + }); + responses.push({ + escalation: Infraction.type !== response.infraction.type, + ...response + }); + } + + 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, + escalation: response.escalation + }; + } + } + } 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 = ""; + if (!silent) { + for (const [, data] of Object.entries(successes)) { + const { dictionary, targetType } = data.infraction; + const reason = data.escalation ? + message.format('INFRACTION_ESCALATIONREASON') : + Util.escapeMarkdown(data.infraction.reason); + 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: !data.escalation ? + ` ${reason.length > 120 ? + `for: \`${reason.substring(0, 117)}...\`` : + `for: \`${reason}\``}` : + ` because \`${reason}\`` + })}`; + data.escalation ? string += str : string = `${str}\n${string}`; //eslint-disable-line + } + } + + for (const [, 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) + })}`; + (!data.escalation && !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; + + } + + async _handleTarget(Infraction, target, info) { + const { guild, reason, force } = info; + const { autoModeration, moderationPoints } = guild._settings; + const { type } = Infraction; + + let points = 0, + expiration = 0; + if (moderationPoints.enabled) { + points = info.points || moderationPoints.points[type]; + expiration = info.expiration || moderationPoints.expirations[type]; + for (const [phrase, amount] of Object.entries(moderationPoints.associations)) { + if (reason.toLowerCase().includes(phrase)) points = amount; + } + } + + const verify = async (infraction, escalated = false) => { + + let verification = infraction.verify(info.executor, target, info.channel); + if (verification instanceof Promise) verification = await verification; + + if (verification.error) return verification; + + if (infraction.targetType === 'USER') { + const userTarget = target instanceof User ? target : target.user; + const oldPoints = await userTarget.totalPoints(guild); + + const newPoints = oldPoints + infraction.points; + if (autoModeration.enabled && points > 0 && !force && !escalated) { + let result = null; + for (let [threshold, action] of Object.entries(autoModeration.thresholds)) { //eslint-disable-line prefer-const + threshold = parseInt(threshold); + if (oldPoints >= threshold) { + if (autoModeration.usePrevious) { + result = { + threshold, + ...action + }; + } + continue; + } + if (newPoints >= threshold) { + result = { + threshold, + ...action + }; + } + } + if (result) { + return { + error: false, + escalation: result, + infraction + }; + } + } + } + + return { error: false, infraction }; + + }; + + const infraction = new Infraction(this.client, { + target, + type, + message: info.message || null, + arguments: info.arguments, + guild: info.guild, + channel: info.channel, + executor: info.executor, + reason: info.reason, + duration: info.duration, + data: info.data, + points, + expiration, + silent: info.silent + }); + + let response = await verify(infraction); + + if (response.escalation) { + if (Constant.Hierarchy[infraction.type] <= Constant.Hierarchy[response.escalation.type]) { + const escalationClass = Constant.Infractions[response.escalation.type]; + const escalationInfraction = new escalationClass(this.client, { + target, + message: info.message || null, + arguments: info.arguments, + type: escalationClass.type, + guild: info.guild, + channel: info.channel, + executor: info.executor, + reason: stripIndents`${reason} + *${guild.format('INFRACTION_AUTOMODESCALATION')}*`, + duration: info.duration, + data: info.data, + points, + expiration, + silent: info.silent + }); + response = await verify(escalationInfraction, true); + } + } + + if (response.error) return response; + + if (response.infraction.targetType === 'USER') { + response.infraction.totalPoints = await response.infraction.target.totalPoints(guild, { + points, expiration, timestamp: response.infraction.timestamp + }); + } + return response.infraction.execute(); + + } + + 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.logger.debug(`Resolving infraction ${i.id}`); + const undoClass = Constant.Infractions[Constants.InfractionOpposites[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 = 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) { + const executor = guild.members.resolve(i.executor) || guild.me; + + try { + await new undoClass(this.client, { + type: undoClass.type, + reason: `AUTO-${Constants.InfractionOpposites[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(); + } catch (error) { + this.logger.error(`Error when resolving infraction:\n${error.stack || error}`); + } + } else { + //Target left guild or channel was removed from the guild. What should happen in this situation? + //Maybe continue checking if the user rejoins, but the channel will always be gone. + } + + //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.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.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); + } + + async _fetchTarget(guild, targetId, targetType, user = false) { + + let target = null; + if (targetType === 'USER') { + try { + target = await guild.members.fetch(targetId); + } catch (error) { + if (user) { + target = await this.client.users.fetch(targetId); + } + } + } else if (targetType === 'CHANNEL') { + target = guild.channels.resolve(targetId); + } + + return target; + + } + + async findLatestInfraction(type, target) { + + const callback = this.callbacks.filter((c) => { + return c.infraction.type === type + && c.infraction.target === target.id; + }).first(); + + if (callback) return callback.infraction; + const result = await this.client.storageManager.mongodb.infractions.findOne( + { type, target }, + { sort: { timestamp: -1 } } + ); + return result || null; + + } + +} + +module.exports = ModerationManager; \ No newline at end of file