galactic-bot/structure/client/components/observers/Automoderation.js

513 lines
20 KiB
JavaScript
Raw Normal View History

2020-11-13 20:13:25 +01:00
const { inspect } = require('util');
const similarity = require('similarity');
2020-11-13 20:13:25 +01:00
const { stripIndents } = require('common-tags');
const { Observer, BinaryTree } = require('../../../interfaces');
2021-06-09 03:25:33 +02:00
const { FilterUtil, FilterPresets, Util } = require('../../../../util');
2020-09-30 23:02:54 +02:00
const { Warn, Mute, Kick, Softban, Ban } = require('../../../moderation/infractions');
const CONSTANTS = {
Infractions: {
WARN: Warn,
MUTE: Mute,
KICK: Kick,
SOFTBAN: Softban,
BAN: Ban
}
};
2020-09-21 20:49:49 +02:00
2021-05-04 16:16:04 +02:00
// TODO:
// Clean up commented out code once testing of new code is done
// Implement missing automod features
module.exports = class AutoModeration extends Observer {
2020-09-21 20:49:49 +02:00
constructor(client) {
super(client, {
name: 'autoModeration',
2021-06-09 01:45:34 +02:00
priority: 1,
disabled: false
2020-09-21 20:49:49 +02:00
});
this.hooks = [
['message', this.filterWords.bind(this)],
['messageUpdate', this.filterWords.bind(this)],
2020-11-11 23:59:27 +01:00
['message', this.flagMessages.bind(this)],
['messageUpdate', this.flagMessages.bind(this)],
2020-09-21 20:49:49 +02:00
['message', this.filterLinks.bind(this)],
['messageUpdate', this.filterLinks.bind(this)],
['message', this.filterInvites.bind(this)],
['messageUpdate', this.filterInvites.bind(this)],
['message', this.filterMentions.bind(this)]
];
this.whitelist = new BinaryTree(this.client, FilterPresets.whitelist);
2020-09-21 20:49:49 +02:00
}
2021-05-04 16:16:04 +02:00
_moderate(action, guild, channel, member, moderationPoints, silent, reason, filterResult, message) {
this.client.moderationManager.autoModerate({ guild, action, message, reason, silent, data: { filterResult } });
/* this.client.moderationManager.handleInfraction(
2020-11-13 20:13:25 +01:00
CONSTANTS.Infractions[action.type],
{ // Hacky patched together message object with just the required stuff for modmanager to work
guild,
member: guild.me,
channel,
arguments: {
force: {
value: action.force
},
points: {
value: action.points || moderationPoints.points[action.type]
},
expiration: {
value: action.expiration || moderationPoints.expirations[action.type]
},
silent
},
format: guild.format.bind(guild),
// eslint-disable-next-line no-empty-function
respond: () => { }
},
{
targets: [member],
reason,
duration: action.duration,
data: {
filterResult
}
}
2021-05-04 16:16:04 +02:00
);*/
2020-11-13 20:13:25 +01:00
}
2020-09-21 20:49:49 +02:00
async filterWords(message, edited) {
2021-06-09 03:25:33 +02:00
const { guild, author, channel, command } = message;
if (!guild || author.bot) return;
const member = message.member || await guild.members.fetch(author.id).catch();
const settings = await guild.settings();
2020-09-30 23:02:54 +02:00
const { wordFilter: setting, moderationPoints } = settings;
2020-11-11 23:59:27 +01:00
const { bypass, ignore, enabled, silent, explicit, fuzzy, regex, whitelist, actions } = setting;
const roles = member?.roles.cache.map((r) => r.id) || [];
2021-06-08 22:28:52 +02:00
if (!enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) return;
2021-06-09 03:25:33 +02:00
if (command?.name === 'settings') {
// NOTE: probably needs a more permanent solution
2021-06-11 03:22:04 +02:00
const result = await this.client.registry.components.get('inhibitor:permissions').execute(message, message.command);
2021-06-09 03:25:33 +02:00
if (!result.error) return;
}
// Which message obj to work with
const msg = edited || message;
2021-06-06 18:08:51 +02:00
if (!msg.content) return;
2020-11-13 20:13:25 +01:00
let log = `Message filter debug:`;
log += `\nPre norm: ${msg.cleanContent}`;
2021-06-09 02:28:43 +02:00
let content = null;
try { // NOTE: Remove try-catch after debug
2021-06-09 03:25:33 +02:00
content = FilterUtil.normalise(Util.removeMarkdown(msg.cleanContent));
2021-06-09 02:28:43 +02:00
} catch (err) {
this.client.logger.error(`Error in message filtering:\n${err.stack}\n${msg.cleanContent}`);
2021-06-09 02:28:43 +02:00
return;
}
2021-05-04 16:16:04 +02:00
log += `\nNormalised: ${content}`;
2020-09-30 23:02:54 +02:00
2021-06-09 14:06:00 +02:00
const catcher = (ln) => {
return () => this.client.logger.debug(`Issue with promise on line ${ln}`);
};
2020-09-30 23:02:54 +02:00
// match: what was matched |
// matched: did it match at all ? |
// matcher: what gets shown in the message logs |
// _matcher: locally used variable for which word in the list triggered it |
// type: which detection type matched it
2020-11-11 23:59:27 +01:00
let filterResult = { match: null, matched: false, matcher: null, _matcher: null, preset: false };
const words = content.toLowerCase().split(' ').filter((elem) => elem.length);
// Remove any potential bypass characters
2020-11-13 20:13:25 +01:00
//const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, ''));
// 1. Filter for preset lists
2020-11-11 23:59:27 +01:00
// CHANGED BEHAVIOUR OF PRESETS, THEY NOW USE THE REGEX PART OF THE FILTER
// if (presets.length) {
// for (const preset of presets) {
// const text = _words.join('').replace(/\s/u, ''); //Also check for spaced out words, ex "f u c k"
// //Combine array of presets to one expression
// const _regex = new RegExp(`(${FilterPresets[preset].join(')|(')})`, 'ui');
// const match = content.match(_regex) || text.length === words.length ? text.match(_regex) : null;
// if (!match) continue;
// this.client.logger.debug(`\nMessage matched with "${preset}" preset list.\nMatch: ${match[0]}\nFull content: ${content}`);
// filterResult = {
// match: match[0],
// matched: true,
// matcher: preset,
// preset,
// type: 'preset'
// };
// break;
// }
// }
// 2. Filter explicit - no bypass checking (unless you count normalising the text, i.e. emoji letters => normal letters)
2020-09-30 23:02:54 +02:00
if (explicit.length && !filterResult.matched) {
2020-11-11 23:59:27 +01:00
//filterResult = FilterUtil.filterExplicit(words, explicit);
// if(filterResult)
for (const word of explicit) {
//Do it like this instead of regex so it doesn't match stuff like Scunthorpe with cunt
if (words.some((_word) => _word === word)) {
2020-11-13 20:13:25 +01:00
log += `\nMessage matched with "${word}" in the explicit list.\nFull content: ${content}`;
2020-09-30 23:02:54 +02:00
filterResult = {
match: word,
matched: true,
matcher: 'explicit',
_matcher: word,
type: 'explicit'
};
2020-11-11 23:59:27 +01:00
break;
}
}
}
2021-06-09 16:36:55 +02:00
// 3. Filter regex
if (regex.length && !filterResult.matched) {
for (const reg of regex) {
const match = content.toLowerCase().match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); // (?:^|\\s) |un
2021-06-09 16:36:55 +02:00
if (match) {
//log += `\next reg: ${tmp}`;
2021-06-12 17:55:24 +02:00
const fullWord = words.find((word) => word.includes(match[1]));
2021-06-12 15:35:34 +02:00
let inWL = false;
try { // This is for debugging only
inWL = this.whitelist.find(fullWord);
} catch (err) {
this.client.logger.debug(fullWord, match[1], words);
2021-06-12 15:35:34 +02:00
}
if (inWL || whitelist.some((word) => word === fullWord)) continue;
log += `\nMessage matched with "${reg}" in the regex list.\nMatch: ${match[0]}, Full word: ${fullWord}\nFull content: ${content}`;
2021-06-09 16:36:55 +02:00
filterResult = {
match: fullWord,
2021-06-09 16:36:55 +02:00
matched: true,
_matcher: match[1].toLowerCase(),
2021-06-09 16:36:55 +02:00
matcher: `Regex: __${reg}__`,
type: 'regex'
};
break;
}
}
}
// 4. Filter fuzzy
2020-09-30 23:02:54 +02:00
if (fuzzy.length && !filterResult.matched) {
const text = words.join('').replace(/\s/u, '');
const threshold = 0.93 - 0.165 * Math.log(text.length);
outer:
for (const _word of fuzzy) {
for (const word of words) {
const sim = similarity(word, _word);
const threshold = 0.93 - 0.165 * Math.log(word.length);
2020-09-30 23:11:44 +02:00
if (sim >= threshold && Math.abs(_word.length - word.length) < 3) {
if (this.whitelist.find(word) || whitelist.some((w) => w === word) && sim < 1) continue;
2020-11-13 20:13:25 +01:00
log += `\nMessage matched with "${_word}" in fuzzy.\nMatched word: ${word}\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`;
2020-09-30 23:02:54 +02:00
filterResult = {
match: word,
matched: true,
_matcher: _word,
matcher: `fuzzy [\`${_word}\`, \`${sim}\`, \`${threshold}\`]`,
type: 'fuzzy'
};
break outer;
}
}
const sim = similarity(text, _word);
2020-09-30 23:11:44 +02:00
if (sim >= threshold && Math.abs(_word.length - text.length) < 3) {
if (this.whitelist.find(text) || whitelist.some((w) => w === text) && sim < 1) continue;
2020-11-13 20:13:25 +01:00
log += `\nMessage matched with "${_word}" in fuzzy.\nMatched word: ${text}\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`;
2020-09-30 23:02:54 +02:00
filterResult = {
match: text,
matched: true,
_matcher: _word,
matcher: `fuzzy [\`${_word}\`, \`${sim}\`, \`${threshold}\`]`,
type: 'fuzzy'
};
break;
}
2020-09-30 23:52:59 +02:00
//this.client.logger.debug(`Message did not match with "${_word}" in fuzzy.\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`);
}
}
// 5. Remove message, inline response and add a reason to msg object
2020-09-30 23:02:54 +02:00
if (!filterResult.matched) return;
msg.filtered = filterResult;
2020-11-13 20:13:25 +01:00
log += `\nFilter result: ${inspect(filterResult)}`;
if (!silent) {
2021-06-09 14:06:00 +02:00
const res = await this.client.rateLimiter.limitSend(msg.channel, msg.format('W_FILTER_DELETE', { user: author.id }), undefined, 'wordFilter').catch(catcher(255));
2021-05-06 00:09:45 +02:00
//const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } });
2021-06-09 14:06:00 +02:00
if (res) res.delete({ timeout: 10000 }).catch(catcher(257));
}
// 6. Automated actions
2020-09-30 23:02:54 +02:00
if (actions.length) {
let action = actions.find((act) => {
return act.trigger.includes(filterResult._matcher);
});
if (!action) action = actions.find((act) => {
return act.trigger === filterResult.type;
});
if (!action) action = actions.find((act) => {
return act.trigger === 'generic';
});
2020-11-13 20:13:25 +01:00
if (!action) {
this.client.logger.debug(log);
2021-06-09 14:06:00 +02:00
this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(275));
2020-11-13 20:13:25 +01:00
}
2020-09-30 23:02:54 +02:00
msg.filtered.sanctioned = true;
2021-06-09 14:06:00 +02:00
this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(279));
2020-11-13 20:13:25 +01:00
this.client.logger.debug(log + '\nSanctioned');
2020-11-11 23:59:27 +01:00
// NOTE: this will have to be changed whenever the moderation manager is finished and properly supports sth like this
2021-05-04 16:16:04 +02:00
this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('W_FILTER_ACTION'), filterResult, message);
2020-11-13 20:13:25 +01:00
} else {
2021-06-09 14:06:00 +02:00
this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(286));
2020-11-13 20:13:25 +01:00
this.client.logger.debug(log);
}
2020-11-11 23:59:27 +01:00
}
async flagMessages(message, edited) {
const { guild, author, channel } = message;
if (!guild || author.bot) return;
const member = message.member || await guild.members.fetch(author.id).catch();
const settings = await guild.settings();
const { wordWatcher: setting } = settings;
const { words, bypass, ignore, channel: _logChannel } = setting;
const roles = member?.roles.cache.map((r) => r.id) || [];
if (!_logChannel || words.length === 0 || roles.some((r) => bypass.includes(r.id)) || ignore.includes(channel.id)) return;
const logChannel = await guild.resolveChannel(_logChannel);
const msg = edited || message;
2021-06-06 18:08:51 +02:00
if (!msg.content) return;
let content = null;
try {
content = FilterUtil.normalise(msg.cleanContent);
} catch (err) {
this.client.logger.error(`Error in message flag:\n${err.stack}\n${msg.cleanContent}`);
return;
}
2020-11-11 23:59:27 +01:00
let match = null;
for (const reg of words) {
2021-06-15 22:46:50 +02:00
match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu'));
2020-11-11 23:59:27 +01:00
if (match) break;
2020-09-30 23:02:54 +02:00
}
2020-11-11 23:59:27 +01:00
if (!match) return;
const context = channel.messages.cache.sort((m1, m2) => m2.createdTimestamp - m1.createdTimestamp).first(5);
const embed = {
title: `⚠️ Word trigger in **#${channel.name}**`,
description: stripIndents`
**[Jump to message](${msg.link})**
`, // ** User:** <@${ author.id }>
color: 15120384,
fields: context.reverse().reduce((acc, val) => {
2021-06-15 22:46:50 +02:00
const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match[1], '**__$&__**') : '**NO CONTENT**';
2020-11-11 23:59:27 +01:00
acc.push({
name: `${val.author.tag} (${val.author.id}) - ${val.id}`,
value: text.length < 1024 ? text : text.substring(0, 1013) + '...'
});
if (text.length > 1024) acc.push({
name: `\u200b`,
value: '...' + text.substring(1013, 2034)
});
return acc;
}, [])
};
2021-06-15 22:46:50 +02:00
const sent = await logChannel.send({ embed }).catch((err) => {
this.client.logger.error('Error in message flag:\n' + err.stack);
});
2020-11-11 23:59:27 +01:00
2020-09-21 20:49:49 +02:00
}
async filterLinks(message, edited) {
2020-11-13 20:13:25 +01:00
const { guild, author, channel } = message;
if (!guild || author.bot) return;
const member = message.member || await guild.members.fetch(author.id).catch();
const { resolver } = this.client;
const settings = await guild.settings();
const { linkFilter: setting, moderationPoints } = settings;
const { bypass, ignore, actions, silent, enabled, blacklist, whitelist, mode } = setting;
if (!enabled) return;
const roles = member?.roles.cache.map((r) => r.id) || [];
if (roles.some((r) => bypass.includes(r.id)) || ignore.includes(channel.id)) return;
const msg = edited || message;
2021-06-06 18:08:51 +02:00
if (!msg.content) return;
2020-11-13 20:13:25 +01:00
const content = msg.content.split('').join(''); //Copy the string...
const linkRegG = /(https?:\/\/(www\.)?)?(?<domain>([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iug;
const linkReg = /(https?:\/\/(www\.)?)?(?<domain>([a-z0-9-]{1,63}\.)?([a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})(\.[a-z0-9-]{2,63})?)(\/\S*)?/iu;
let matches = content.match(linkRegG);
if (!matches) matches = content.replace(/\s/u, '').match(linkRegG);
if (!matches) return;
let remove = false;
const filterResult = {};
const _whitelist = mode === 'whitelist';
for (const match of matches) {
const { domain } = match.match(linkReg).groups;
// eslint-disable-next-line brace-style
if (!_whitelist && blacklist.some((dom) => { return dom.includes(domain) || domain.includes(dom); })) {
filterResult.match = domain;
filterResult.matcher = 'link blacklist';
remove = true;
break;
} else if (_whitelist) {
// eslint-disable-next-line brace-style
if (whitelist.some((dom) => { return dom.includes(domain) || domain.includes(dom); })) continue;
const valid = await resolver.validateDomain(domain);
if (!valid) continue;
filterResult.match = domain;
filterResult.matcher = 'link whitelist';
remove = true;
break;
}
}
if (!remove) return;
msg.filtered = filterResult;
if (!silent) {
2021-05-06 00:09:45 +02:00
const res = await this.client.rateLimiter.limitSend(msg.channel, msg.format('L_FILTER_DELETE', { user: author.id }), undefined, 'linkFilter');
//const res = await msg.formattedRespond(`L_FILTER_DELETE`, { params: { user: author.id } });
if (res) res.delete({ timeout: 10000 });
2020-11-13 20:13:25 +01:00
}
if (actions.length) {
let action = actions.find((act) => {
return act.trigger.includes(filterResult.match);
});
if (!action) action = actions.find((act) => {
return act.trigger === mode;
});
if (!action) action = actions.find((act) => {
return act.trigger === 'generic';
});
if (!action) return msg.delete();
2021-05-08 11:33:41 +02:00
msg.filtered.sanctioned = true;
2021-05-06 00:09:45 +02:00
this.client.rateLimiter.queueDelete(msg.channel, msg);
//msg.delete();
2020-11-13 20:13:25 +01:00
2021-05-04 16:16:04 +02:00
this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('L_FILTER_ACTION'), filterResult, message);
2020-11-13 20:13:25 +01:00
2021-05-06 00:09:45 +02:00
} else this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete();
2020-11-13 20:13:25 +01:00
2020-09-21 20:49:49 +02:00
}
async filterInvites(message, edited) {
2020-11-11 23:59:27 +01:00
const { guild, author, channel } = message;
if (!guild || author.bot) return;
const member = message.member || await guild.members.fetch(author.id).catch();
const settings = await guild.settings();
const { inviteFilter: setting, moderationPoints } = settings;
const { bypass, ignore, actions, silent, enabled } = setting;
if (!enabled) return;
2020-11-11 23:59:27 +01:00
const roles = member?.roles.cache.map((r) => r.id) || [];
if (roles.some((r) => bypass.includes(r.id)) || ignore.includes(channel.id)) return;
2020-11-11 23:59:27 +01:00
const msg = edited || message;
const { content } = msg;
2021-06-06 18:08:51 +02:00
if (!content) return;
2020-11-13 20:13:25 +01:00
const reg = /((discord)?\s?\.?\s?gg\s?|discord(app)?\.com\/invite)\/\s?(?<code>[a-z0-9]+)/iu;
const match = content.match(reg);
if (!match) return;
const result = await guild.checkInvite(match.groups.code);
if (!result) { // Doesn't resolve to the origin server
let action = null;
if (actions.length) [action] = actions;
msg.filtered = {
match: match[0],
matcher: 'invites'
};
2021-05-06 00:09:45 +02:00
if (!action) return this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete();
if (!silent) {
2021-05-06 00:09:45 +02:00
const res = await this.client.rateLimiter.limitSend(msg.channel, msg.format('I_FILTER_DELETE', { user: author.id }), undefined, 'inviteFilter');
if (res) res.delete({ timeout: 10000 });
}
msg.filtered.sactioned = true;
2021-05-06 00:09:45 +02:00
this.client.rateLimiter.queueDelete(msg.channel, msg);
2021-05-04 16:16:04 +02:00
this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('I_FILTER_ACTION'), { filtered: msg.filtered }, message);
}
2020-11-11 23:59:27 +01:00
2020-09-21 20:49:49 +02:00
}
2021-06-06 10:46:35 +02:00
async filterMentions(message) {
const { guild, author, channel } = message;
if (!guild || author.bot) return;
const member = message.member || await guild.members.fetch(author.id).catch();
const settings = await guild.settings();
const { mentionFilter: setting, moderationPoints } = settings;
const { bypass, ignore, enabled, silent, unique, mentions, actions } = setting;
const roles = member?.roles.cache.map((r) => r.id) || [];
if (!enabled || roles.some((r) => bypass.includes(r.id)) || ignore.includes(channel.id)) return;
const reg = /<@!?[0-9]{18,22}>/gu;
const { content } = message;
2021-06-06 18:08:51 +02:00
if (!content) return;
2021-06-06 10:46:35 +02:00
//const mentions = content.match(reg);
2020-09-21 20:49:49 +02:00
}
};