const similarity = require('similarity'); const { Observer, BinaryTree } = require('../../../interfaces'); const { FilterUtil, FilterPresets } = require('../../../../util'); const { Warn, Mute, Kick, Softban, Ban } = require('../../../moderation/infractions'); const CONSTANTS = { Infractions: { WARN: Warn, MUTE: Mute, KICK: Kick, SOFTBAN: Softban, BAN: Ban } }; module.exports = class AutoModeration extends Observer { constructor(client) { super(client, { name: 'autoModeration', 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); } 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(); 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; this.client.logger.debug(`Pre norm: ${msg.cleanContent}`); const content = FilterUtil.normalize(msg.cleanContent); 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 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; 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) 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)) { 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 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 && Math.abs(_word.length - word.length) < 3) { if (this.whitelist.find(word) || whitelist.some((w) => w === word) && sim < 1) continue; 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 && Math.abs(_word.length - text.length) < 3) { if (this.whitelist.find(text) || whitelist.some((w) => w === text) && sim < 1) continue; 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 if (tokenized.length && !filterResult.matched) { for (const word of tokenized) { if (content.toLowerCase().includes(word)) { 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 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 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 } } ); } } async filterLinks(message, edited) { } async filterInvites(message, edited) { } async filterMentions(message) { } };