diff --git a/package.json b/package.json index 6cdd02a..3e854ab 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "request": "^2.88.2", "similarity": "^1.2.1", "timestring": "^6.0.0", + "twemoji-parser": "^13.1.0", "winston": "^3.2.1", "winston-transport": "^4.3.0" }, diff --git a/structure/client/RateLimiter.js b/structure/client/RateLimiter.js index ef3e011..77776c1 100644 --- a/structure/client/RateLimiter.js +++ b/structure/client/RateLimiter.js @@ -16,9 +16,10 @@ class RateLimiter { this.deleteTimeouts = {}; //same as above this.lastSend = {}; //used by limitSend + this.lastDelete = {}; this.sendInterval = 7.5; //How frequently sending is allowed in seconds - this.deleteInterval = 1.5; //How frequently delete queues should be executed + this.deleteInterval = 2.5; //How frequently delete queues should be executed } @@ -41,7 +42,8 @@ class RateLimiter { if(!this.deleteQueue[channel.id]) this.deleteQueue[channel.id] = []; this.deleteQueue[channel.id].push({ message, resolve, reject }); - if(!this.deleteTimeouts[channel.id] || this.deleteTimeouts[channel.id]._destroyed) this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval*1000, channel); + //if(!this.deleteTimeouts[channel.id] || this.deleteTimeouts[channel.id]._destroyed) this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval*1000, channel); + this.delete(channel); }); @@ -56,14 +58,23 @@ class RateLimiter { queue = [...this.deleteQueue[channel.id]], deleteThese = []; - for(const item of queue) { + const lastDelete = this.lastDelete[channel.id]; + const now = Math.floor(Date.now() / 1000); + if (now - lastDelete < this.deleteInterval) { + const timeout = this.deleteTimeouts[channel.id]; + if (!timeout || timeout._destroyed) this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval*1000, channel); + return; + } + this.lastDelete[channel.id] = now; + + for(const item of queue) { // Organise into arrays const { message, resolve, reject } = item; if(deleteThese.length <= 100) { deleteThese.push(message); resolves.push(resolve); rejects.push(reject); this.deleteQueue[channel.id].shift(); - } else { + } else { // left over messages go in next batch this.deleteTimeouts[channel.id] = setTimeout(this.delete.bind(this), this.deleteInterval*1000, channel); break; } diff --git a/structure/client/Resolver.js b/structure/client/Resolver.js index 8c5e9c7..d98cf2a 100644 --- a/structure/client/Resolver.js +++ b/structure/client/Resolver.js @@ -1,6 +1,7 @@ const timestring = require('timestring'); const moment = require('moment'); const dns = require('dns'); +const { parse: resolveTwemoji } = require('twemoji-parser'); const { InfractionResolves } = require('../../util/Constants.js'); // eslint-disable-next-line no-unused-vars @@ -593,9 +594,9 @@ class Resolver { const { roles } = guild; const resolved = []; - for(const resolveable of resolveables) { + const id = /^(<@&)?([0-9]{16,22})>?/iu; - const id = /^(<@&)?([0-9]{16,22})>?/iu; + for(const resolveable of resolveables) { if(id.test(resolveable)) { @@ -710,6 +711,74 @@ class Resolver { return { parsed, parameters }; } + + /** + * Resolves input into an emoji, either a custom or a unicode emoji. Returned object will have a type property indicating whether it's custom or unicode. + * + * @param {Array} [resolveables=[]] + * @param {boolean} strict + * @param {Guild} guild + * @return {Object} + * @memberof Resolver + */ + async resolveEmojis(resolveables = [], strict, guild) { + + if (typeof resolveables === 'string') resolveables = [resolveables]; + if (resolveables.length === 0) return false; + //const { emojis: guildEmojis } = guild; + const { emojis: clientEmojis } = this.client; + const resolved = []; + + const emojiMention = /<(?:a)?:?([\w-]{2,32}):(\d{17,32})>/iu; + + for (const resolveable of resolveables) { + + if (emojiMention.test(resolveable)) { + + const match = resolveable.match(emojiMention); + const [, , eId] = match; + + const emoji = clientEmojis.resolve(eId); //.catch(this.client.logger.error); // use .fetch(eId) once v13 rolls out + + if (emoji) resolved.push({ type: 'custom', emoji }); + + } else { + + const sorter = (a, b) => a.name.length - b.name.length; + const filter = (e) => { + if (!strict) return e.name.toLowerCase().includes(resolveable.toLowerCase()); + return e.name.toLowerCase() === resolveable.toLowerCase(); + }; + let emoji = null; + if (guild) emoji = guild.emojis.cache.sort(sorter).filter(filter).first(); + if (!emoji) emoji = clientEmojis.cache.sort(sorter).filter(filter).first(); + if (emoji) { + resolved.push({ type: 'custom', emoji }); + continue; + } else { // twemoji parse + const [result] = resolveTwemoji(resolveable); + if (result) { + ({ text: emoji } = result); + resolved.push({ type: 'unicode', emoji }); + } + } + + } + + } + + return resolved.length > 0 ? resolved : false; + + } + + async resolveEmoji(resolveable, strict, guild) { + + if (!resolveable) return false; + if (resolveable instanceof Array) throw new Error('Resolveable cannot be of type Array, use resolveEmojis for resolving arrays of emojis'); + const result = await this.resolveEmojis([resolveable], strict, guild); + return result ? result[0] : false; + + } } diff --git a/structure/client/components/commands/developer/Stats.js b/structure/client/components/commands/developer/Stats.js index ca45879..22cb2f1 100644 --- a/structure/client/components/commands/developer/Stats.js +++ b/structure/client/components/commands/developer/Stats.js @@ -82,7 +82,7 @@ class StatsCommand extends Command { }); return acc; }, {}); - totalValues.uptime = this.client.resolver.timeAgo(Math.floor(totalValues.uptime / 1000), true, true, true); + totalValues.uptime = this.client.resolver.timeAgo(Math.floor(totalValues.uptime/ shard.count / 1000), true, true, true); totalValues.avgMem = Math.floor(totalValues.memory / shard.count); //System information diff --git a/structure/client/components/observers/Automoderation.js b/structure/client/components/observers/Automoderation.js index 8728d89..2048cf2 100644 --- a/structure/client/components/observers/Automoderation.js +++ b/structure/client/components/observers/Automoderation.js @@ -182,12 +182,23 @@ module.exports = class AutoModeration extends Observer { for (const reg of regex) { - const match = content.match(new RegExp(reg, 'iu')); + const match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); // (?:^|\\s) |un if (match) { - log += `\nMessage matched with "${reg}" in the regex list.\nMatch: ${match[0]}\nFull content: ${content}`; + //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: match[0], + match: fullWord, matched: true, _matcher: match[0].toLowerCase(), matcher: `Regex: __${reg}__`, @@ -310,7 +321,7 @@ module.exports = class AutoModeration extends Observer { for (const reg of words) { - match = content.match(new RegExp(reg, 'iu')); + match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); if (match) break; @@ -326,7 +337,7 @@ module.exports = class AutoModeration extends Observer { `, // ** User:** <@${ author.id }> color: 15120384, fields: context.reverse().reduce((acc, val) => { - const text = val.content.length ? val.content.replace(match, '**__$&__**') : '**NO CONTENT**'; + 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) + '...' @@ -339,7 +350,9 @@ module.exports = class AutoModeration extends Observer { }, []) }; - logChannel.send({ embed }); + const sent = await logChannel.send({ embed }).catch((err) => { + this.client.logger.error('Error in message flag:\n' + err.stack); + }); } diff --git a/structure/client/components/observers/CommandHandler.js b/structure/client/components/observers/CommandHandler.js index 7edbdaa..915c0a5 100644 --- a/structure/client/components/observers/CommandHandler.js +++ b/structure/client/components/observers/CommandHandler.js @@ -389,15 +389,16 @@ class CommandHandler extends Observer { const silent = inhibitors.filter((i) => i.inhibitor.silent); const nonsilent = inhibitors.filter((i) => !i.inhibitor.silent); - if(nonsilent.length === 0 && silent.length > 0) return undefined; - if(nonsilent.length > 0) return this.handleError(message, { type: 'inhibitor', ...nonsilent[0] }); + if (silent.length && silent.some((result) => result.inhibitor.id === 'channelIgnore')) return; + if (nonsilent.length === 0 && silent.length > 0) return undefined; + if (nonsilent.length > 0) return this.handleError(message, { type: 'inhibitor', ...nonsilent[0] }); const resolved = await message.resolve(); - if(resolved.error) { + if (resolved.error) { this.client.logger.error(`Command Error | ${message.command.resolveable} | Message ID: ${message.id}\n${resolved.message.stack || resolved.message}`); - if(resolved.message.code === 50013) { + if (resolved.message.code === 50013) { const missing = message.channel.permissionsFor(message.guild.me).missing(['EMBED_LINKS']); - if(missing.length > 0) { + if (missing.length > 0) { return message.respond(message.format('COMMANDHANDLER_COMMAND_MISSINGPERMISSIONS'), { emoji: 'failure' }); @@ -428,7 +429,7 @@ class CommandHandler extends Observer { const reasons = (await Promise.all(promises)).filter((p) => p.error); // Filters out inhibitors with only errors. if(reasons.length === 0) return []; - reasons.sort((a, b) => b.inhibitor.priority - a.inhibitor.priority); // Sorts inhibitor errors by most important. + reasons.sort((a, b) => a.inhibitor.priority - b.inhibitor.priority); // Sorts inhibitor errors by most important. return reasons; } diff --git a/structure/client/components/observers/GuildLogging.js b/structure/client/components/observers/GuildLogging.js index 51fb317..59c258e 100644 --- a/structure/client/components/observers/GuildLogging.js +++ b/structure/client/components/observers/GuildLogging.js @@ -72,7 +72,11 @@ class GuildLogger extends Observer { await message.guild.settings(); - if (!message.member) message.member = await message.guild.members.fetch(message.author.id).catch(); + if (!message.member) try { + message.member = await message.guild.members.fetch(message.author.id); + } catch (_) { + // Member not found, do nothing + } const { messageLog } = message.guild._settings; if(!messageLog.channel) return undefined; @@ -99,30 +103,42 @@ class GuildLogger extends Observer { return; } + const { reference, channel, author, content, id } = message; + const embed = { // author: { // name: message.format('MSGLOG_DELETE_TITLE', { channel: message.channel.name, author: Util.escapeMarkdown(message.author.tag) }), //`${message.author.tag} (${message.author.id})`, // icon_url: message.author.displayAvatarURL({ size: 32 }) //eslint-disable-line camelcase // }, - title: message.format('MSGLOG_DELETE_TITLE', { channel: message.channel.name, author: Util.escapeMarkdown(message.author.tag) }), - description: Util.escapeMarkdown(message.content)?.replace(/\\n/gu, ' ') || message.format('MSGLOG_NOCONTENT'), + title: message.format('MSGLOG_DELETE_TITLE', { channel: channel.name, author: Util.escapeMarkdown(author.tag) }), + description: Util.escapeMarkdown(content)?.replace(/\\n/gu, ' ') || message.format('MSGLOG_NOCONTENT'), color: CONSTANTS.COLORS.RED, footer: { - text: message.format('MSGLOG_DELETE_FOOTER', { msgID: message.id, userID: message.author.id }) + text: message.format('MSGLOG_DELETE_FOOTER', { msgID: id, userID: author.id }) }, - timestamp: message.createdAt + timestamp: message.createdAt, + fields: [] }; + + if (reference && reference.channelID === channel.id) { + const referenced = await channel.messages.fetch(reference.messageID); + embed.fields.push({ + name: message.format('MSGLOG_REPLY', { tag: referenced.author.tag, id: referenced.author.id }), + value: message.format('MSGLOG_REPLY_VALUE', { + content: referenced.content.length > 900 ? referenced.content.substring(0, 900) + '...' : referenced.content, + link: referenced.url + }) + }); + } if (message.filtered) { - embed.fields = [ - { - name: message.format('MSGLOG_FILTERED'), - value: stripIndents` + embed.fields.push({ + name: message.format('MSGLOG_FILTERED'), + value: stripIndents` ${message.format(message.filtered.preset ? 'MSGLOG_FILTERED_PRESET' : 'MSGLOG_FILTERED_VALUE', { ...message.filtered })} ${message.filtered.sanctioned ? message.format('MSGLOG_FILTERED_SANCTIONED') : ''} `// + () - } - ]; + }); } const uploadedFiles = []; @@ -203,7 +219,9 @@ class GuildLogger extends Observer { } - hook.send({ embeds: [embed], files: uploadedFiles }); + await hook.send({ embeds: [embed], files: uploadedFiles }).catch((err) => { + this.client.logger.error('Error in message delete:\n' + err.stack); + }); /* if(message.attachments.size > 0) { @@ -337,7 +355,7 @@ class GuildLogger extends Observer { if (!guild) return; if (!oldMessage.member) oldMessage.member = await guild.members.fetch(oldMessage.author); - const { member, channel, author } = oldMessage; + const { member, channel, author, reference } = oldMessage; const settings = await guild.settings(); const chatlogs = settings.messageLog; @@ -386,16 +404,7 @@ class GuildLogger extends Observer { description: oldMessage.format('MSGLOG_EDIT_JUMP', { guild: guild.id, channel: channel.id, message: oldMessage.id }), color: CONSTANTS.COLORS.YELLOW, timestamp: oldMessage.createdAt, - fields: [ - // { - // name: oldMessage.format('MSGLOG_EDIT_OLD'), - // value: oldMessage.content.length > 1024 ? oldMessage.content.substring(0, 1021) + '...' : oldMessage.content - // }, - // { - // name: oldMessage.format('MSGLOG_EDIT_NEW'), - // value: newMessage.content.length > 1024 ? newMessage.content.substring(0, 1021) + '...' : newMessage.content - // } - ] + fields: [ ] }; const oldCon = oldMessage.content, @@ -419,6 +428,19 @@ class GuildLogger extends Observer { name: '\u200b', value: '...' + newCon.substring(1021) }); + + if (reference && reference.channelID === channel.id) { + const referenced = await channel.messages.fetch(reference.messageID); + // eslint-disable-next-line no-nested-ternary + const content = referenced.content ? referenced.content.length > 900 ? referenced.content.substring(0, 900) + '...' : referenced.content : oldMessage.format('MSGLOG_REPLY_NOCONTENT'); + embed.fields.push({ + name: oldMessage.format('MSGLOG_REPLY', { tag: referenced.author.tag, id: referenced.author.id }), + value: oldMessage.format('MSGLOG_REPLY_VALUE', { + content, + link: referenced.url + }) + }); + } //if(oldMessage.content.length > 1024) embed.description += '\n' + oldMessage.format('MSGLOG_EDIT_OLD_CUTOFF'); //if(newMessage.content.length > 1024) embed.description += '\n' + oldMessage.format('MSGLOG_EDIT_NEW_CUTOFF'); diff --git a/structure/client/components/settings/moderation/InviteFilter.js b/structure/client/components/settings/moderation/InviteFilter.js index e68593a..11dddcd 100644 --- a/structure/client/components/settings/moderation/InviteFilter.js +++ b/structure/client/components/settings/moderation/InviteFilter.js @@ -120,6 +120,11 @@ module.exports = class InviteFilter extends FilterSetting { } + if (langParams.error) return { + error: true, + msg: langParams.msg + }; + await message.guild._updateSettings({ [this.index]: setting }); return { error: false, diff --git a/structure/client/components/settings/moderation/LinkFilter.js b/structure/client/components/settings/moderation/LinkFilter.js index 0d04b72..218424c 100644 --- a/structure/client/components/settings/moderation/LinkFilter.js +++ b/structure/client/components/settings/moderation/LinkFilter.js @@ -202,6 +202,11 @@ module.exports = class LinkFilter extends FilterSetting { msg: message.format('ERR_INVALID_METHOD', { method }) }; + if (langParams.error) return { + error: true, + msg: langParams.msg + }; + await message.guild._updateSettings({ [this.index]: setting }); return { error: false, diff --git a/structure/client/components/settings/moderation/MentionFilter.js b/structure/client/components/settings/moderation/MentionFilter.js index 2170c62..9912535 100644 --- a/structure/client/components/settings/moderation/MentionFilter.js +++ b/structure/client/components/settings/moderation/MentionFilter.js @@ -149,6 +149,11 @@ module.exports = class MentionFilter extends FilterSetting { msg: message.format('ERR_INVALID_METHOD', { method }) }; + if (langParams.error) return { + error: true, + msg: langParams.msg + }; + await message.guild._updateSettings({ [this.index]: setting }); return { error: false, diff --git a/structure/client/components/settings/moderation/WordFilter.js b/structure/client/components/settings/moderation/WordFilter.js index f6ebc19..8580260 100644 --- a/structure/client/components/settings/moderation/WordFilter.js +++ b/structure/client/components/settings/moderation/WordFilter.js @@ -53,6 +53,7 @@ module.exports = class WordFilter extends FilterSetting { async handle(message, params) { + // eslint-disable-next-line prefer-const let [method, ...args] = params; method = method.toLowerCase(); @@ -254,6 +255,11 @@ module.exports = class WordFilter extends FilterSetting { }; } + if (langParams.error) return { + error: true, + msg: langParams.msg + }; + await message.guild._updateSettings({ [this.index]: setting }); return { error: false, @@ -263,6 +269,7 @@ module.exports = class WordFilter extends FilterSetting { } async _createTrigger(message, action, actionObject, setting) { + const response = await message.prompt(message.format('S_WORDFILTER_ACTION_ADD_TRIGGERS'), { time: 60 * 1000 }); if (!response) { if (setting.actions.find((ac) => ac.trigger === 'generic')) return { diff --git a/structure/interfaces/BinaryTree.js b/structure/interfaces/BinaryTree.js index 464e3f4..47ed616 100644 --- a/structure/interfaces/BinaryTree.js +++ b/structure/interfaces/BinaryTree.js @@ -64,7 +64,7 @@ class BinaryTree { */ find(val) { - val.toLowerCase(); + val = val.toLowerCase(); if(this.isEmpty()) return; diff --git a/structure/language/languages/en_us/en_us_observers.lang b/structure/language/languages/en_us/en_us_observers.lang index d7bee7a..b01743a 100644 --- a/structure/language/languages/en_us/en_us_observers.lang +++ b/structure/language/languages/en_us/en_us_observers.lang @@ -72,6 +72,16 @@ Link filter violation. [MSGLOG_NOCONTENT] **__NO TEXT CONTENT__** +[MSGLOG_REPLY] +Message was in reply to user {tag} ({id}): + +[MSGLOG_REPLY_VALUE] +**[Jump to message]({link})** +{content} + +[MSGLOG_REPLY_NOCONTENT] +**__Missing content.__** + [MSGLOG_FILTERED] The message was filtered: diff --git a/structure/language/languages/en_us/en_us_settings.lang b/structure/language/languages/en_us/en_us_settings.lang index cce8d59..c3f367c 100644 --- a/structure/language/languages/en_us/en_us_settings.lang +++ b/structure/language/languages/en_us/en_us_settings.lang @@ -52,6 +52,8 @@ Can be one of `{valid}`. > You can cancel this series of prompts by responding with `cancel`. +{wordwatcher} + [S_FILTER_ACTION_ADD_TIMER] Would you like the **{action}** to have a timer? Not assigning a timer will use defaults for the corresponding action. @@ -219,6 +221,11 @@ Successfully removed all presets from the regex filter. [S_WORDWATCHER_DESCRIPTION] Configure the behaviour of the word watcher. +Wordwatcher is a moderation utility that flags messages for manual review based on keywords. +Keywords can be regex expressions. + +Wordwatcher also supports having 5 reactions for quick actions. + [S_WORDWATCHER_TOGGLE] Successfully toggled the word watcher **{toggle}**. @@ -261,6 +268,18 @@ Successfully removed **{changes}** from the watch list. [S_WORDWATCHER_CHANNEL] Will log flagged messages to <#{channel}>. +[S_WORDWATCHER_ACTION_LIMIT] +You've hit the limit of quick actions. Either modify or remove existing ones. + +[S_WORDWATCHER_ACTION_ADD_START] +You can only define up to 5 quick actions, you currently have {amount} existing actions. + +[S_WORDWATCHER_ACTION_ADD_TRIGGERS] +Which emoji should represent this action? +Make sure it is one that the bot has access to (i.e. from this server or one you know the bot is in). + +The bot will use defaults if no emoji is given. + // Wordfilter [S_WORDFILTER_DESCRIPTION] Configure the word filtering behaviour for your server. diff --git a/util/filterPresets.json b/util/filterPresets.json index 99f66df..2761c65 100644 --- a/util/filterPresets.json +++ b/util/filterPresets.json @@ -1,8 +1,7 @@ { "regex": { "slurs": [ - "n(ae|ji|j|y|i|x|!|1|\\||l)(gg?|qq|99?|bb)(?!(ht|el|un))((e|3)r|let|ur|\\s?nog|y|ah?|or)?s?", - "(?