moderation manager
This commit is contained in:
parent
4b95e67579
commit
62d2fe48ab
466
src/structure/client/ModerationManager.js
Normal file
466
src/structure/client/ModerationManager.js
Normal file
@ -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;
|
Loading…
Reference in New Issue
Block a user