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

252 lines
9.4 KiB
JavaScript
Raw Normal View History

const similarity = require('similarity');
const { Observer, BinaryTree } = require('../../../interfaces');
const { FilterUtil, FilterPresets } = 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
module.exports = class AutoModeration extends Observer {
2020-09-21 20:49:49 +02:00
constructor(client) {
super(client, {
name: 'autoModeration',
2020-09-21 20:49:49 +02:00
priority: 1
});
this.hooks = [
['message', this.filterWords.bind(this)],
['messageUpdate', this.filterWords.bind(this)],
['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
}
async filterWords(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();
2020-09-30 23:02:54 +02:00
const { wordFilter: setting, moderationPoints } = settings;
const { bypass, ignore, enabled, silent, explicit, fuzzy, tokenized, whitelist, actions, presets } = setting;
const roles = member.roles.cache.map((r) => r.id);
if (!enabled || roles.some((r) => bypass.includes(r.id)) || ignore.includes(channel.id)) return;
// Which message obj to work with
const msg = edited || message;
2020-09-30 23:02:54 +02:00
this.client.logger.debug(`Pre norm: ${msg.cleanContent}`);
const content = FilterUtil.normalize(msg.cleanContent);
2020-09-30 23:02:54 +02:00
this.client.logger.debug(`Normalized: ${content}`);
// 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 |
// filter: which filter list was used
// type: which detection type matched it
let filterResult = { match: null, matched: false, matcher: null, _matcher: null, preset: false, filter: null };
const words = content.toLowerCase().split(' ').filter((elem) => elem.length);
// Remove any potential bypass characters
2020-09-30 23:02:54 +02:00
const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, ''));
// 1. Filter for preset lists
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;
2020-09-30 23:02:54 +02:00
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) {
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-09-30 23:02:54 +02:00
this.client.logger.debug(`\nMessage matched with "${word}" in the explicit list.\nFull content: ${content}`);
filterResult = {
match: word,
matched: true,
matcher: 'explicit',
_matcher: word,
type: 'explicit'
};
}
}
}
// 3. 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);
if (sim >= threshold) {
if (this.whitelist.find(word) || whitelist.some((w) => w === word) && sim < 1) continue;
2020-09-30 23:02:54 +02:00
this.client.logger.debug(`\nMessage matched with "${_word}" in fuzzy.\nMatched word: ${word}\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`);
filterResult = {
match: word,
matched: true,
_matcher: _word,
matcher: `fuzzy [\`${_word}\`, \`${sim}\`, \`${threshold}\`]`,
type: 'fuzzy'
};
break outer;
}
}
const sim = similarity(text, _word);
if (sim >= threshold) {
if (this.whitelist.find(text) || whitelist.some((w) => w === text) && sim < 1) continue;
2020-09-30 23:02:54 +02:00
this.client.logger.debug(`\nMessage matched with "${_word}" in fuzzy.\nMatched word: ${text}\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`);
filterResult = {
match: text,
matched: true,
_matcher: _word,
matcher: `fuzzy [\`${_word}\`, \`${sim}\`, \`${threshold}\`]`,
type: 'fuzzy'
};
break;
}
this.client.logger.debug(`Message did not match with "${_word}" in fuzzy.\nFull content: ${content}\nSimilarity: ${sim}\nThreshold: ${threshold}`);
}
}
// 4. Filter tokenized
2020-09-30 23:02:54 +02:00
if (tokenized.length && !filterResult.matched) {
2020-09-30 23:02:54 +02:00
for (const word of tokenized) {
if (content.toLowerCase().includes(word)) {
2020-09-30 23:02:54 +02:00
this.client.logger.debug(`\nMessage matched with "${word}" in the tokenized list.\nFull content: ${content}`);
filterResult = {
match: word,
matched: true,
_matcher: word,
matcher: 'tokenized',
type: 'tokenized'
};
}
}
}
// 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;
if (!silent) {
const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } });
res.delete({ timeout: 10000 });
}
// 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';
});
if (!action) return msg.delete();
msg.filtered.sanctioned = true;
await msg.delete();
this.client.moderationManager.handleInfraction(
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: setting.silent
},
format: guild.format.bind(guild),
// eslint-disable-next-line no-empty-function
respond: () => {}
},
{
targets: [member],
reason: msg.format('W_FILTER_ACTION'),
duration: action.duration,
data: {
filterResult
}
}
);
}
2020-09-21 20:49:49 +02:00
}
async filterLinks(message, edited) {
}
async filterInvites(message, edited) {
}
async filterMentions(message) {
}
};