const { inspect } = require('util'); const similarity = require('similarity'); const { stripIndents } = require('common-tags'); const { Observer, BinaryTree } = require('../../../interfaces'); const { FilterUtil, FilterPresets, Util } = require('../../../../util'); const { Warn, Mute, Kick, Softban, Ban } = require('../../../moderation/infractions'); const CONSTANTS = { Infractions: { WARN: Warn, MUTE: Mute, KICK: Kick, SOFTBAN: Softban, BAN: Ban } }; // TODO: // Clean up commented out code once testing of new code is done // Implement missing automod features module.exports = class AutoModeration extends Observer { constructor(client) { super(client, { name: 'autoModeration', priority: 1, disabled: false }); this.hooks = [ ['message', this.filterWords.bind(this)], ['messageUpdate', this.filterWords.bind(this)], ['message', this.flagMessages.bind(this)], ['messageUpdate', this.flagMessages.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); } _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( 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 } } );*/ } async filterWords(message, edited) { 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(); const { wordFilter: setting, moderationPoints } = settings; const { bypass, ignore, enabled, silent, explicit, fuzzy, regex, whitelist, actions } = setting; const roles = member?.roles.cache.map((r) => r.id) || []; if (!enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) return; if (command?.name === 'settings') { // NOTE: probably needs a more permanent solution const result = await this.client.registry.components.get('inhibitor:permissions').execute(message, message.command); if (!result.error) return; } // Which message obj to work with const msg = edited || message; if (!msg.content) return; let log = `Message filter debug:`; log += `\nPre norm: ${msg.cleanContent}`; let content = null; try { // NOTE: Remove try-catch after debug content = FilterUtil.normalise(Util.removeMarkdown(msg.cleanContent)); } catch (err) { this.client.logger.debug(log); return; } log += `\nNormalised: ${content}`; const catcher = (ln) => { return () => this.client.logger.debug(`Issue with promise on line ${ln}`); }; // 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 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 //const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, '')); // 1. Filter for preset lists // 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) if (explicit.length && !filterResult.matched) { //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)) { log += `\nMessage matched with "${word}" in the explicit list.\nFull content: ${content}`; filterResult = { match: word, matched: true, matcher: 'explicit', _matcher: word, type: 'explicit' }; break; } } } // 3. Filter regex if (regex.length && !filterResult.matched) { for (const reg of regex) { const match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); // (?:^|\\s) |un if (match) { //log += `\next reg: ${tmp}`; const fullWord = words.find((word) => word.includes(match[1])); let inWL = false; try { // This is for debugging only inWL = this.whitelist.find(fullWord); } catch (err) { this.client.logger.debug(fullWord, match[0], words); } 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}`; filterResult = { match: fullWord, matched: true, _matcher: match[0].toLowerCase(), matcher: `Regex: __${reg}__`, type: 'regex' }; break; } } } // 4. 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; log += `\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; log += `\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}`); } } // 5. Remove message, inline response and add a reason to msg object if (!filterResult.matched) return; msg.filtered = filterResult; log += `\nFilter result: ${inspect(filterResult)}`; if (!silent) { const res = await this.client.rateLimiter.limitSend(msg.channel, msg.format('W_FILTER_DELETE', { user: author.id }), undefined, 'wordFilter').catch(catcher(255)); //const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } }); if (res) res.delete({ timeout: 10000 }).catch(catcher(257)); } // 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) { this.client.logger.debug(log); this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(275)); } msg.filtered.sanctioned = true; this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(279)); this.client.logger.debug(log + '\nSanctioned'); // NOTE: this will have to be changed whenever the moderation manager is finished and properly supports sth like this this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('W_FILTER_ACTION'), filterResult, message); } else { this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(286)); this.client.logger.debug(log); } } 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; if (!msg.content) return; const content = FilterUtil.normalise(msg.cleanContent); let match = null; for (const reg of words) { match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); if (match) break; } 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) => { const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match[1], '**__$&__**') : '**NO CONTENT**'; 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; }, []) }; const sent = await logChannel.send({ embed }).catch((err) => { this.client.logger.error('Error in message flag:\n' + err.stack); }); } async filterLinks(message, edited) { 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; if (!msg.content) return; const content = msg.content.split('').join(''); //Copy the string... const linkRegG = /(https?:\/\/(www\.)?)?(?([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\.)?)?(?([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) { 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 }); } 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(); msg.filtered.sanctioned = true; this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete(); this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('L_FILTER_ACTION'), filterResult, message); } else this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete(); } async filterInvites(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 { inviteFilter: setting, moderationPoints } = settings; const { bypass, ignore, actions, silent, enabled } = 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; const { content } = msg; if (!content) return; const reg = /((discord)?\s?\.?\s?gg\s?|discord(app)?\.com\/invite)\/\s?(?[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' }; if (!action) return this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete(); if (!silent) { 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; this.client.rateLimiter.queueDelete(msg.channel, msg); this._moderate(action, guild, channel, member, moderationPoints, silent, msg.format('I_FILTER_ACTION'), { filtered: msg.filtered }, message); } } 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; if (!content) return; //const mentions = content.match(reg); } };