diff --git a/src/structure/components/observers/Automoderation.js b/src/structure/components/observers/Automoderation.js new file mode 100644 index 0000000..1103a89 --- /dev/null +++ b/src/structure/components/observers/Automoderation.js @@ -0,0 +1,495 @@ +const { inspect } = require('util'); +const similarity = require('similarity'); +const { stripIndents } = require('common-tags'); + +const { Observer } = require('../../interfaces'); +const { BinaryTree, Util, FilterUtil } = require('../../../utilities'); +const { FilterPresets } = require('../../../constants'); +const { Warn, Mute, Kick, Softban, Ban } = require('../infractions'); +const { GuildWrapper } = require('../../client/wrappers'); + +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 = [ + ['messageCreate', this.filterWords.bind(this)], + ['messageUpdate', this.filterWords.bind(this)], + ['messageCreate', this.flagMessages.bind(this)], + ['messageUpdate', this.flagMessages.bind(this)], + ['messageCreate', this.filterLinks.bind(this)], + ['messageUpdate', this.filterLinks.bind(this)], + ['messageCreate', this.filterInvites.bind(this)], + ['messageUpdate', this.filterInvites.bind(this)], + ['messageCreate', this.filterMentions.bind(this)] + ]; + + this.whitelist = new BinaryTree(this.client, FilterPresets.whitelist); + this.executing = {}; + + } + + async _moderate(action, wrapper, channel, member, reason, filterResult) { + + // Prevent simultaneous execution of the same filter on the same user when spamming + if (!this.executing[filterResult.filter]) this.executing[filterResult.filter] = []; + if (this.executing[filterResult.filter].includes(member.id)) return; + this.executing[filterResult.filter].push(member.id); + + const InfractionClass = CONSTANTS.Infractions[action.type]; + const result = await this.client.moderationManager._handleTarget(InfractionClass, member, { + wrapper, + channel, + executor: wrapper.guild.me, + reason, + duration: action.duration, + points: action.points, + expiration: action.expiration, + silent: true, //Won't DM Users. + force: false, + data: { + automoderation: filterResult + } + }).catch(this.logger.error.bind(this.logger)); + + await Util.wait(5000); + this.executing[filterResult.filter].splice(this.executing[filterResult.filter].indexOf(member.id), 1); + + } + + async filterWords(message, edited) { + + const { guild, author, channel, guildWrapper: wrapper } = message; + if (!guild || author.bot) return; + + const member = message.member || await guild.members.fetch(author.id).catch(); + const settings = await wrapper.settings(); + const { wordfilter: setting } = 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; + + // 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 = Util.removeMarkdown(msg.cleanContent); + if (!content) return; + content = FilterUtil.normalise(content); + } catch (err) { + this.logger.error(`Error in message filtering:\n${err.stack}\n${msg.cleanContent}`); + return; + } + log += `\nNormalised: ${content}`; + + const catcher = (ln) => { + return () => this.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 = { filter: 'word', match: null, matched: false, matcher: null, _matcher: null, preset: false }; + const words = content.toLowerCase().replace(/[,?.!]/gu, '').split(' ').filter((elem) => elem.length); + // Remove any potential bypass characters + //const _words = words.map((word) => word.replace(/[.'*_?+"#%&=-]/gu, '')); + + // 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.toLowerCase().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.logger.debug(fullWord, match[1], 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[1].toLowerCase(), + matcher: `Regex: __${reg}__`, + type: 'regex' + }; + break; + } + + } + + } + + // 4. Filter fuzzy + if (fuzzy.length && !filterResult.matched) { + + const text = words.join('').replace(/\s/u, ''); + let threshold = (0.93 - 0.133 * Math.log(text.length)).toFixed(3); + if (threshold < 0.6) threshold = 0.6; + else if (threshold > 0.9) threshold = 0.9; + + outer: + for (const _word of fuzzy) { + + for (const word of words) { + const sim = similarity(word, _word); + let threshold = (0.93 - 0.133 * Math.log(word.length)).toFixed(3); + if (threshold < 0.6) threshold = 0.6; + else if (threshold > 0.9) threshold = 0.9; + if (sim >= threshold && Math.abs(_word.length - word.length) <= 2) { + 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) <= 2) { + 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, wrapper.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(240)); + setTimeout(() => { + res.delete().catch(() => { /**/ }); + }, 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) { + this.logger.debug(log); + return 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.logger.debug(log + '\nSanctioned'); + + filterResult.filter = 'word'; + this._moderate(action, wrapper, channel, member, wrapper.format('W_FILTER_ACTION'), filterResult, message); + + } else { + this.client.rateLimiter.queueDelete(msg.channel, msg).catch(catcher(269)); + this.logger.debug(log); + } + + } + + async flagMessages(message, edited) { + + const { guild, author, channel, guildWrapper: wrapper } = message; + if (!guild || author.bot) return; + + const member = message.member || await guild.members.fetch(author.id).catch(); + const settings = await wrapper.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 wrapper.resolveChannel(_logChannel); + const msg = edited || message; + if (!msg.content) return; + let content = null; + try { + content = FilterUtil.normalise(msg.cleanContent); + } catch (err) { + this.logger.error(`Error in message flag:\n${err.stack}\n${msg.cleanContent}`); + return; + } + 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({ embeds: [embed] }).catch((err) => { + this.logger.error('Error in message flag:\n' + err.stack); + }); + + } + + async filterLinks(message, edited) { + + const { guild, author, channel, guildWrapper: wrapper } = message; + if (!guild || author.bot) return; + + const member = message.member || await guild.members.fetch(author.id).catch(); + const { resolver } = this.client; + const settings = await wrapper.settings(); + const { linkfilter: setting } = 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)) || 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, wrapper.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 }); + setTimeout(() => { + res.delete().catch(() => { /**/ }); + }, 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) this.client.rateLimiter.queueDelete(msg.channel, msg); + + msg.filtered.sanctioned = true; + this.client.rateLimiter.queueDelete(msg.channel, msg); + //msg.delete(); + + filterResult.filter = 'link'; + this._moderate(action, guild, channel, member, wrapper.format('L_FILTER_ACTION', { domain: filterResult.match }), filterResult, message); + + } else this.client.rateLimiter.queueDelete(msg.channel, msg); //msg.delete(); + + } + + async filterInvites(message, edited) { + + const { guild, author, channel, guildWrapper: wrapper } = message; + if (!guild || author.bot) return; + + const member = message.member || await guild.members.fetch(author.id).catch(); + const settings = await wrapper.settings(); + const { invitefilter: setting } = 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)) || 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, wrapper.format('I_FILTER_DELETE', { user: author.id }), undefined, 'inviteFilter'); + //if (res) res.delete({ timeout: 10000 }); + setTimeout(() => { + res.delete().catch(() => { /**/ }); + }, 10000); + } + msg.filtered.sactioned = true; + this.client.rateLimiter.queueDelete(msg.channel, msg); + + this._moderate(action, guild, channel, member, wrapper.format('I_FILTER_ACTION'), { filtered: msg.filtered }, message); + + } + + } + + async filterMentions(message) { + + const { guild, author, channel, guildWrapper: wrapper } = message; + if (!guild || author.bot) return; + + const member = message.member || await guild.members.fetch(author.id).catch(); + const settings = await wrapper.settings(); + const { mentionfilter: setting, modpoints } = 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)) || ignore.includes(channel.id)) return; + + const reg = /<@!?[0-9]{18,22}>/gu; + const { content } = message; + if (!content) return; + //const mentions = content.match(reg); + + } + +}; \ No newline at end of file diff --git a/src/structure/components/observers/GuildLogging.js b/src/structure/components/observers/GuildLogging.js index cf6b27f..74820c3 100644 --- a/src/structure/components/observers/GuildLogging.js +++ b/src/structure/components/observers/GuildLogging.js @@ -1,11 +1,11 @@ /* eslint-disable no-labels */ const { MessageAttachment, WebhookClient } = require('discord.js'); - -const { Observer } = require('../../interfaces/'); -const Util = require('../../../Util'); -const { Constants: { EmbedLimits } } = require('../../../constants'); const { stripIndents } = require('common-tags'); const moment = require('moment'); + +const { Observer } = require('../../interfaces/'); +const { Util } = require('../../../utilities'); +const { Constants: { EmbedLimits } } = require('../../../constants'); const { GuildWrapper } = require('../../client/wrappers'); const CONSTANTS = { @@ -52,8 +52,8 @@ class GuildLogger extends Observer { this.attachmentWebhook = new WebhookClient( { - id: this.client._options.discord.moderation.attachments.webhook.id, - token: this.client._options.discord.moderation.attachments.webhook.token + id: process.env.MODERATION_WEHBHOOK_ID, + token: process.env.MODERATION_WEHBHOOK_TOKEN } ); @@ -104,7 +104,7 @@ class GuildLogger extends Observer { const hook = await wrapper.getWebhook('messages'); if (!hook) { - this.client.logger.debug(`Missing messageLog hook in ${message.guild.name} (${message.guild.id})`); + this.logger.debug(`Missing messageLog hook in ${message.guild.name} (${message.guild.id})`); return; } @@ -200,7 +200,7 @@ class GuildLogger extends Observer { const upload = async (files) => { const attachmentMessage = await this.attachmentWebhook.send(null, files) - .catch((error) => this.client.logger.error(error)); + .catch((error) => this.logger.error(error)); attachmentMessage.attachments.map( (a) => uploaded.push(`[${a.filename} (${(a.size / CONSTANTS.IMAGES.MB_DIVIDER).toFixed(2)}mb)](${a.url})`) ); @@ -229,7 +229,7 @@ class GuildLogger extends Observer { } await hook.send({ embeds: [embed], files: uploadedFiles }).catch((err) => { - this.client.logger.error('Error in message delete:\n' + err.stack); + this.logger.error('Error in message delete:\n' + err.stack); }); } @@ -238,10 +238,9 @@ class GuildLogger extends Observer { //Status: Should be complete, though additional testing might be necessary - const { guild, channel } = messages.first(); + const { guild, channel, guildWrapper: wrapper } = messages.first(); if (!guild) return; - const wrapper = new GuildWrapper(this.client, guild); const settings = await wrapper.settings(); const chatlogs = settings.messages; if (!chatlogs.channel) return; @@ -311,7 +310,7 @@ class GuildLogger extends Observer { const upload = async (files) => { const attachmentMessage = await this.attachmentWebhook.send(null, files) - .catch((error) => this.client.logger.error(error)); + .catch((error) => this.logger.error(error)); attachmentMessage.attachments.map( (a) => uploaded.push(`[${a.filename} (${(a.size / CONSTANTS.IMAGES.MB_DIVIDER).toFixed(2)}mb)](${a.url})`) ); @@ -395,7 +394,7 @@ class GuildLogger extends Observer { //Unknown webhook -> webhook was deleted, remove it from db so it doesn't make any more unnecessary calls if (err.code === 10015) { wrapper.updateWebhook('messages'); - } else this.client.logger.error(err.stack); + } else this.logger.error(err.stack); return { error: true }; }); if (result.error) break; @@ -411,10 +410,9 @@ class GuildLogger extends Observer { if (oldMessage.embeds.length !== newMessage.embeds.length && oldMessage.content === newMessage.content) return; if (oldMessage.author.bot) return; - const { guild } = oldMessage; + const { guild, guildWrapper: wrapper } = oldMessage; if (!guild) return; - const wrapper = new GuildWrapper(this.client, guild); if (!oldMessage.member) oldMessage.member = await guild.members.fetch(oldMessage.author); const { member, channel, author, reference } = oldMessage; @@ -454,7 +452,7 @@ class GuildLogger extends Observer { if (img.height && img.width) embed.image = { url: img.url }; } - await hook.send({ embeds: [embed] }).catch(this.client.logger.error); + await hook.send({ embeds: [embed] }).catch(this.logger.error); } else { @@ -517,7 +515,7 @@ class GuildLogger extends Observer { //if(newMessage.content.length > 1024) embed.description += '\n' + oldMessage.format('MSGLOG_EDIT_NEW_CUTOFF'); await hook.send({ embeds: [embed] }).catch((err) => { - this.client.logger.error('Error in message edit:\n' + err.stack); + this.logger.error('Error in message edit:\n' + err.stack); }); } @@ -527,11 +525,10 @@ class GuildLogger extends Observer { async voiceState(oldState, newState) { if (oldState.channel && newState.channel && oldState.channel === newState.channel) return; - const { guild, member } = oldState; + const { guild, member, guildWrapper: wrapper } = oldState; //TODO: add checks for disconnecting bot from vc when left alone in one (music player) - const wrapper = new GuildWrapper(this.client, guild); const settings = await wrapper.settings(); const setting = settings.voice; if (!setting || !setting.channel) return; @@ -582,8 +579,7 @@ class GuildLogger extends Observer { async memberJoin(member) { - const { guild } = member; - const wrapper = new GuildWrapper(this.client, guild); + const { guild, guildWrapper: wrapper } = member; const settings = await wrapper.settings(); const setting = settings.members; if (!setting.channel) return; @@ -602,8 +598,7 @@ class GuildLogger extends Observer { async memberLeave(member) { - const { guild } = member; - const wrapper = new GuildWrapper(this.client, guild); + const { guild, guildWrapper: wrapper } = member; const settings = await wrapper.settings(); const setting = settings.members; if (!setting.channel) return; @@ -624,8 +619,7 @@ class GuildLogger extends Observer { if (oldMember.nickname === newMember.nickname) return; - const { guild, user } = oldMember; - const wrapper = new GuildWrapper(this.client, guild); + const { guild, user, guildWrapper: wrapper } = oldMember; const settings = await wrapper.settings(); const setting = settings.nicknames; if (!setting.channel) return;