2020-09-30 18:32:48 +02:00
|
|
|
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');
|
2020-11-11 23:59:27 +01:00
|
|
|
const { stripIndents } = require('common-tags');
|
2020-09-30 23:02:54 +02:00
|
|
|
|
|
|
|
const CONSTANTS = {
|
|
|
|
Infractions: {
|
|
|
|
WARN: Warn,
|
|
|
|
MUTE: Mute,
|
|
|
|
KICK: Kick,
|
|
|
|
SOFTBAN: Softban,
|
|
|
|
BAN: Ban
|
|
|
|
}
|
|
|
|
};
|
2020-09-21 20:49:49 +02:00
|
|
|
|
2020-09-30 18:32:48 +02:00
|
|
|
module.exports = class AutoModeration extends Observer {
|
2020-09-21 20:49:49 +02:00
|
|
|
|
|
|
|
constructor(client) {
|
|
|
|
|
|
|
|
super(client, {
|
2020-09-30 18:32:48 +02:00
|
|
|
name: 'autoModeration',
|
2020-09-21 20:49:49 +02:00
|
|
|
priority: 1
|
|
|
|
});
|
|
|
|
|
|
|
|
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)]
|
|
|
|
];
|
|
|
|
|
2020-09-30 18:32:48 +02:00
|
|
|
this.whitelist = new BinaryTree(this.client, FilterPresets.whitelist);
|
|
|
|
|
2020-09-21 20:49:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async filterWords(message, edited) {
|
|
|
|
|
2020-09-30 18:32:48 +02: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();
|
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) || [];
|
2020-09-30 18:32:48 +02:00
|
|
|
|
|
|
|
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}`);
|
2020-09-30 18:32:48 +02:00
|
|
|
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 |
|
|
|
|
// 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 };
|
2020-09-30 18:32:48 +02:00
|
|
|
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, ''));
|
2020-09-30 18:32:48 +02:00
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
// }
|
|
|
|
// }
|
2020-09-30 18:32:48 +02:00
|
|
|
|
|
|
|
// 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-09-30 18:32:48 +02:00
|
|
|
|
2020-11-11 23:59:27 +01:00
|
|
|
//filterResult = FilterUtil.filterExplicit(words, explicit);
|
|
|
|
// if(filterResult)
|
|
|
|
|
2020-09-30 18:32:48 +02:00
|
|
|
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'
|
|
|
|
};
|
2020-11-11 23:59:27 +01:00
|
|
|
break;
|
2020-09-30 18:32:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// 3. Filter fuzzy
|
2020-09-30 23:02:54 +02:00
|
|
|
if (fuzzy.length && !filterResult.matched) {
|
2020-09-30 18:32:48 +02:00
|
|
|
|
|
|
|
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) {
|
2020-09-30 18:32:48 +02:00
|
|
|
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'
|
|
|
|
};
|
2020-09-30 18:32:48 +02:00
|
|
|
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) {
|
2020-09-30 18:32:48 +02:00
|
|
|
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'
|
|
|
|
};
|
2020-09-30 18:32:48 +02:00
|
|
|
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}`);
|
2020-09-30 18:32:48 +02:00
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2020-11-11 23:59:27 +01:00
|
|
|
// 4. Filter regex
|
|
|
|
if (regex.length && !filterResult.matched) {
|
2020-09-30 18:32:48 +02:00
|
|
|
|
2020-11-11 23:59:27 +01:00
|
|
|
for (const reg of regex) {
|
|
|
|
|
|
|
|
const match = content.match(new RegExp(reg, 'iu'));
|
2020-09-30 23:02:54 +02:00
|
|
|
|
2020-11-11 23:59:27 +01:00
|
|
|
if (match) {
|
|
|
|
this.client.logger.debug(`\nMessage matched with "${reg}" in the regex list.\nMatch: ${match[0]}\nFull content: ${content}`);
|
2020-09-30 23:02:54 +02:00
|
|
|
filterResult = {
|
2020-11-11 23:59:27 +01:00
|
|
|
match: match[0],
|
2020-09-30 23:02:54 +02:00
|
|
|
matched: true,
|
2020-11-11 23:59:27 +01:00
|
|
|
_matcher: reg,
|
|
|
|
matcher: `Regex: __${reg}__`,
|
|
|
|
type: 'regex'
|
2020-09-30 23:02:54 +02:00
|
|
|
};
|
2020-11-11 23:59:27 +01:00
|
|
|
break;
|
2020-09-30 18:32:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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-09-30 18:32:48 +02:00
|
|
|
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();
|
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
|
2020-09-30 23:02:54 +02:00
|
|
|
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
|
2020-11-11 23:59:27 +01:00
|
|
|
respond: () => { }
|
|
|
|
},
|
2020-09-30 23:02:54 +02:00
|
|
|
{
|
|
|
|
targets: [member],
|
|
|
|
reason: msg.format('W_FILTER_ACTION'),
|
|
|
|
duration: action.duration,
|
|
|
|
data: {
|
|
|
|
filterResult
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
2020-11-11 23:59:27 +01:00
|
|
|
} else msg.delete();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
const content = FilterUtil.normalize(msg.cleanContent);
|
|
|
|
let match = null;
|
|
|
|
|
|
|
|
for (const reg of words) {
|
|
|
|
|
|
|
|
match = content.match(new RegExp(reg, 'iu'));
|
|
|
|
|
|
|
|
if (match) break;
|
|
|
|
|
2020-09-30 23:02:54 +02:00
|
|
|
}
|
2020-09-30 18:32:48 +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) => {
|
|
|
|
const text = val.content.length ? val.content.replace(match, '**__$&__**') : '**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;
|
|
|
|
}, [])
|
|
|
|
};
|
|
|
|
|
|
|
|
logChannel.send({ embed });
|
|
|
|
|
2020-09-21 20:49:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async filterLinks(message, edited) {
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
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();
|
2020-11-12 19:12:20 +01:00
|
|
|
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) || [];
|
|
|
|
|
2020-11-12 19:12:20 +01:00
|
|
|
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;
|
2020-11-12 19:12:20 +01:00
|
|
|
const { content } = msg;
|
|
|
|
|
|
|
|
const reg = /((discord)?\s?\.?\s?gg\s?|discord\.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'
|
|
|
|
};
|
|
|
|
if (!action) return msg.delete();
|
|
|
|
if (!silent) {
|
|
|
|
const res = await msg.formattedRespond('I_FILTER_DELETE', { params: { user: author.id } });
|
|
|
|
res.delete({ timeout: 10000 });
|
|
|
|
}
|
|
|
|
msg.filtered.sactioned = true;
|
|
|
|
await msg.delete();
|
|
|
|
|
|
|
|
// NOTE: this will have to be changed whenever the moderation manager is finished and properly supports sth like this
|
|
|
|
this.client.moderationManager.handleInfraction(
|
|
|
|
CONSTANTS.Infractions[action.type],
|
|
|
|
{
|
|
|
|
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//,
|
|
|
|
//prune: {
|
|
|
|
// name: 'prune',
|
|
|
|
// value: 50
|
|
|
|
//}
|
|
|
|
},
|
|
|
|
format: guild.format.bind(guild),
|
|
|
|
// eslint-disable-next-line no-empty-function
|
|
|
|
respond: () => {}
|
|
|
|
},
|
|
|
|
{
|
|
|
|
targets: [member],
|
|
|
|
reason: msg.format('I_FILTER_ACTION'),
|
|
|
|
duration: action.duration,
|
|
|
|
data: {
|
|
|
|
filtered: msg.filtered
|
|
|
|
}
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
2020-11-11 23:59:27 +01:00
|
|
|
|
2020-09-21 20:49:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async filterMentions(message) {
|
|
|
|
|
|
|
|
}
|
|
|
|
|
2020-09-30 18:32:48 +02:00
|
|
|
};
|