forked from Galactic/galactic-bot
462 lines
18 KiB
JavaScript
462 lines
18 KiB
JavaScript
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; |