diff --git a/@types/Shared.d.ts b/@types/Shared.d.ts index 81eff58..b241562 100644 --- a/@types/Shared.d.ts +++ b/@types/Shared.d.ts @@ -38,7 +38,7 @@ export type IPCMessage = { respawnDelay: number, timeout: number }, - _mEval?: string, + _mEval?: boolean, _mEvalResult?: boolean _logger?: boolean, _api?: boolean, diff --git a/src/client/DiscordClient.ts b/src/client/DiscordClient.ts index 6d4f508..52bdb59 100644 --- a/src/client/DiscordClient.ts +++ b/src/client/DiscordClient.ts @@ -269,10 +269,8 @@ class DiscordClient extends Client script = `(${script})(this, ${JSON.stringify(options.context)})`; return new Promise((resolve, reject) => { - if (!process.send) - return reject(new Error('No parent process')); this.#evals.set(script as string, { resolve, reject }); - process.send({ _mEval: true, script, debug: options.debug || process.env.NODE_ENV === 'development' }); + this.#intercom.send('mEval', { script, debug: options.debug ?? process.env.NODE_ENV === 'development' }); }); } diff --git a/src/client/components/observers/Automoderation.ts b/src/client/components/observers/Automoderation.ts index 9f60841..f850e6c 100644 --- a/src/client/components/observers/Automoderation.ts +++ b/src/client/components/observers/Automoderation.ts @@ -1,822 +1,822 @@ -import { APIEmbed, APIEmbedField, ButtonComponentData, ButtonStyle, ComponentType, Interaction, MessageCreateOptions, PermissionsString, TextChannel } from 'discord.js'; -import { Ban, Kick, Mute, Softban, Warn } from '../../infractions/index.js'; -import Observer from '../../interfaces/Observer.js'; -import Initialisable from '../../interfaces/Initialisable.js'; -import DiscordClient from '../../DiscordClient.js'; -import BinaryTree from '../../../utilities/BinaryTree.js'; -import Util from '../../../utilities/Util.js'; -import { inspect } from 'util'; -import { FilterUtil } from '../../../utilities/index.js'; -import { FilterResult, Nullable } from '../../../../@types/Utils.js'; -import { ExtendedGuildMember, ExtendedMessage, FormatParams, SettingAction } from '../../../../@types/Client.js'; -import { stripIndents } from 'common-tags'; -import { ZeroWidthChar } from '../../../constants/Constants.js'; -import Infraction from '../../interfaces/Infraction.js'; -import InteractionWrapper from '../wrappers/InteractionWrapper.js'; -import InvokerWrapper from '../wrappers/InvokerWrapper.js'; -import GuildWrapper from '../wrappers/GuildWrapper.js'; -import MemberWrapper from '../wrappers/MemberWrapper.js'; - - -const CONSTANTS: { - Infractions: { [key: string]: typeof Infraction }, - ButtonStyles: { [key: string]: ButtonStyle.Danger }, - Permissions: {[key: string]: PermissionsString} -} = { - Infractions: { - WARN: Warn, - MUTE: Mute, - KICK: Kick, - SOFTBAN: Softban, - BAN: Ban - }, - ButtonStyles: { - BAN: ButtonStyle.Danger, - }, - Permissions: { - WARN: 'KickMembers', - MUTE: 'ModerateMembers', - KICK: 'KickMembers', - SOFTBAN: 'KickMembers', - BAN: 'BanMembers', - DELETE: 'ManageMessages' - } -}; - -export default class AutoModeration extends Observer implements Initialisable -{ - regex: { invite: RegExp; linkRegG: RegExp; linkReg: RegExp; mention: RegExp; mentionG: RegExp; }; - topLevelDomains!: BinaryTree; - executing: { [key: string]: string[] }; - constructor (client: DiscordClient) - { - super(client, { - name: 'autoModeration', - priority: 1, - disabled: false - }); - - this.hooks = [ - [ 'messageCreate', this.filterWords.bind(this) ], - [ 'messageUpdate', this.filterWords.bind(this) ], - [ 'messageCreate', this.filterInvites.bind(this) ], - [ 'messageUpdate', this.filterInvites.bind(this) ], - [ 'messageCreate', this.flagMessages.bind(this) ], - [ 'messageUpdate', this.flagMessages.bind(this) ], - [ 'messageCreate', this.filterLinks.bind(this) ], - [ 'messageUpdate', this.filterLinks.bind(this) ], - [ 'messageCreate', this.filterMentions.bind(this) ], - [ 'interactionCreate', this.flagAction.bind(this) ] - ]; - - this.executing = {}; - - this.regex = { - invite: /((discord\s*\.?\s*gg\s*)|discord(app)?\.com\/invite)\/\s?(?[a-z0-9]+)/iu, - 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, - 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, - mention: /<@!?(?[0-9]{18,22})>/u, - mentionG: /<@!?(?[0-9]{18,22})>/gu, - }; - } - - async initialise () - { - // Fetch a list of TLDs from iana - const tldList = await this.client.managerEval(` - (() => { - return ClientUtils.fetchTlds() - })() - `).catch(this.logger.error.bind(this.logger)) as string[] | undefined; - - if (!tldList) - throw Util.fatal('Failed to initialise automod'); - const middlePoint = Math.floor(tldList.length / 2); - const [ midEntry ] = tldList.splice(middlePoint, 1); - tldList.splice(0, 0, midEntry); - this.topLevelDomains = new BinaryTree(this.client, tldList); - this.topLevelDomains.add('onion'); - } - - async _moderate ( - action: SettingAction, guild: GuildWrapper, channel: TextChannel, - member: MemberWrapper, reason: string, filterResult: FilterResult, moderator?: MemberWrapper - ): Promise - { - // 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 false; - this.executing[filterResult.filter!].push(member.id); - - // Setting this true initially and negate if it fails, otherwise it won't show up as sanctioned in the msg logs - filterResult.sanctioned = true; - const InfractionClass = CONSTANTS.Infractions[action.type]; - const executor = moderator ?? await guild.memberWrapper(this.client.user!); - if (!executor) - throw new Error('Missing executor??'); - const result = await this.client.moderation.handleAutomod(InfractionClass, member, { - guild, - channel, - executor, - reason, - duration: action.duration ? action.duration * 1000 : null, - points: action.points, - expiration: action.expiration, - silent: false, - force: false, - prune: action.prune, - data: { - automoderation: filterResult - } - }).catch(this.logger.error.bind(this.logger)); - filterResult.sanctioned = !result?.error; - - await Util.wait(5000); - this.executing[filterResult.filter!].splice(this.executing[filterResult.filter!].indexOf(member.id), 1); - return !result?.error; - } - - async filterWords (message: ExtendedMessage, edited: ExtendedMessage) - { - const { guildWrapper: guild, author } = message; - let channel = message.channel as TextChannel; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author); - if (!member) - return; - const settings = await guild.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; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'word', permissions: missing }); - 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) - { - const error = err as Error; - this.logger.error(`Error in message filtering:\n${error.stack}\n${msg.cleanContent}`); - return; - } - log += `\nNormalised: ${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 - let filterResult: Partial> = { filter: 'word', match: null, matched: false, matcher: null, _matcher: null }; - 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) - { - const result = FilterUtil.filterExplicit(words, explicit); - if (result) - { - log += `\nMessage matched with "${result.match}" in the explicit list.\nFull content: ${content}`; - filterResult = result; - } - } - - // 3. Filter regex - if (regex.length && !filterResult.matched) - { - const result = FilterUtil.filterRegex(content, regex, whitelist); - if (result) - { - log += `\nMessage matched with "${result.matcher}" in the regex list.\nMatch: ${result.raw}, Full word: ${result.match}\nFull content: ${content}`; - filterResult = result; - } - } - - // 4. Filter fuzzy - if (fuzzy.length && !filterResult.matched) - { - const result = FilterUtil.filterFuzzy(words, fuzzy, whitelist); - if (result) - { - filterResult = result; - log += `\nMessage matched with "${result._matcher}" in fuzzy.\nMatched word: ${result.match}\nFull content: ${content}\nSimilarity: ${result.sim}\nThreshold: ${result.threshold}`; - } - } - - // 5. Remove message, inline response and add a reason to msg object - if (!filterResult.matched) - return; - msg.filtered = filterResult as FilterResult; - filterResult.filter = 'word'; - log += `\nFilter result: ${inspect(filterResult)}`; - - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(msg.channel as TextChannel, guild.format('W_FILTER_DELETE', { user: author.id }), null, 'wordFilter').catch(() => null); - // const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } }); - // if (res) res.delete({ timeout: 10000 }).catch(catcher(240)); - if (res) - setTimeout(() => - { - res?.delete?.().catch(() => - { /**/ }); - }, 10000); - } - this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); - - // 6. Automated actions - if (actions.length) - { - let action = actions.find((act) => - { - return (act.trigger as string).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) - { - log += '\nSanctioned'; - await this._moderate(action, guild, channel, member, guild.format('W_FILTER_ACTION'), filterResult as FilterResult); - } - } - this.logger.debug(`${guild.name} WF DEBUG: ${log}`); - } - - async flagMessages (message: ExtendedMessage, edited: ExtendedMessage) - { - const { guild, author, guildWrapper: wrapper } = message; - let { channel } = message; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot) - return; - - const member = message.member || await guild.members.fetch(author.id).catch(() => null); - const settings = await wrapper.settings(); - const { wordwatcher: setting } = settings; - const { words, regex, bypass, ignore, channel: _logChannel, actions } = setting; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (!_logChannel || words.length === 0 || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const logChannel = await wrapper.resolveChannel(_logChannel); - const msg = edited || message; - if (!msg.content || !logChannel) - return; - let content = null; - try - { - content = FilterUtil.normalise(msg.cleanContent); - } - catch (err) - { - const error = err as Error; - this.logger.error(`Error in message flag:\n${error.stack}\n${msg.cleanContent}`); - return; - } - let match: RegExpMatchArray | string | null = null; - - for (const reg of regex) - { - // match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); - match = content.match(new RegExp(reg, 'iu')); - if (match) - { - [ match ] = match; - break; - } - } - - if (!match) - for (const word of words) - { - if (!content.includes(word)) - continue; - match = content.substring(content.indexOf(word), word.length); - break; - } - - if (!match) - return; - - const context = channel.messages.cache.sort((m1, m2) => m2.createdTimestamp - m1.createdTimestamp).first(5); - const embed: APIEmbed = { - title: `⚠️ Word trigger in **#${channel.name}**`, - description: stripIndents` - **[Jump to message](${msg.url})** - `, // ** User:** <@${ author.id }> - color: 15120384, - fields: context.reverse().reduce((acc, val) => - { - const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match as string, '**__$&__**') : '**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: ZeroWidthChar, - value: '...' + text.substring(1013, 2034) - }); - return acc; - }, [] as APIEmbedField[]) - }; - - const components: ButtonComponentData[] = []; - for (const action of actions) - { - components.push({ - type: ComponentType.Button, - label: action.type, - // eslint-disable-next-line camelcase - customId: `WORDWATCHER_${action.trigger}`, - style: CONSTANTS.ButtonStyles[action.type] || ButtonStyle.Primary - }); - } - - const actionRow = components.length ? [{ - type: ComponentType.ActionRow, - components - }] : null; - const opts: MessageCreateOptions = { - embeds: [ embed ], - components: [] - }; - if (actionRow) - opts.components = actionRow; - const sent = await logChannel.send(opts).catch((err) => - { - this.logger.error('Error in message flag:\n' + err.stack); - }); - - // Only insert if actions are defined - if (actionRow && sent) - await this.client.storageManager.mongodb.wordwatcher.insertOne({ - message: sent.id, - target: msg.id, - channel: msg.channel.id, - timestamp: Date.now() - }); - } - - async flagAction (interaction: Interaction) - { - // console.log(interaction); - if (!interaction.isButton() || !interaction.inGuild()) - return; - const { message, customId } = interaction; - if (!customId.startsWith('WORDWATCHER_')) - return; - const { components } = message; - const [ , actionType ] = customId.split('_'); - const { permissions } = this.client; - - const guild = this.client.getGuildWrapper(interaction.guildId); - if (!guild) - return; - const moderator = await guild.memberWrapper(interaction.user.id); - if (!moderator) - return; - const settings = await guild.settings(); - const { wordwatcher } = settings; - const { actions } = wordwatcher; - - await interaction.deferUpdate(); - const fail = (index: string, opts?: FormatParams) => - { - this.client.emit('wordWatcherError', { - warning: true, - guild, - message: guild.format(index, opts) - }); - (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = ButtonStyle.Danger; - return message.edit({ components }); - }; - - // TODO: finish perm checks, need to figure out a workaround for `command:delete` - // actionType === 'DELETE' ? - // { error: false } : - const wrapper = new InteractionWrapper(this.client, interaction); - const invoker = new InvokerWrapper(this.client, wrapper); - const { inhibitor, error, params } - = await permissions.execute( - invoker, - { resolveable: `command:${actionType.toLowerCase()}`, memberPermissions: [] }, - [ CONSTANTS.Permissions[actionType] ] - ); - - if (error) - { - const missing = moderator.permissions.missing('ManageMessages'); - if (params.missing !== 'command:delete') - return fail(inhibitor.index, { command: actionType, missing: params.missing }); - else if (missing.length) - return fail(inhibitor.index, { missing: missing.join(', ') }); - } - - const log = await this.client.storageManager.mongodb.wordwatcher.findOne({ - message: message.id - }); - if (!log) - return fail('WORDWATCHER_MISSING_LOG'); - - const action = actions.find((act) => act.trigger === actionType); - if (!action) - return fail('WORDWATCHER_MISSING_ACTION', { actionType }); - - const targetChannel = await guild.resolveChannel(log.channel).catch(() => null); - if (!targetChannel) - return fail('WORDWATCHER_MISSING_CHANNEL'); - - const filterObj = { - filter: 'wordwatcher', - action: action.type - }; - const msg = await targetChannel.messages.fetch(log.target).catch(() => null) as ExtendedMessage; - if (msg) - { - await msg.delete().catch(() => null); - msg.filtered = filterObj; - } - - await this.client.storageManager.mongodb.wordwatcher.deleteOne({ - message: message.id - }); - - let success = false; - if (action.type === 'DELETE') - { - success = true; - } - else - { - const member = await guild.memberWrapper(message.author); - if (member) - success = await this._moderate(action, guild, targetChannel, member, guild.format('WORDWATCHER_ACTION'), filterObj, moderator); - else - this.client.emit('wordWatcherError', { warning: true, guild, message: guild.format('WORDWATCHER_MISSING_MEMBER', { actionType }) }); - } - - (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = success - ? ButtonStyle.Success : ButtonStyle.Secondary; - await message.edit({ components }).catch(() => null); - } - - // eslint-disable-next-line max-lines-per-function - async filterLinks (message: ExtendedMessage, edited: ExtendedMessage) - { - const { author, guildWrapper: guild, channel } = message; - if (!channel) - { - this.logger.warn(`Missing channel?\nChannelId: ${message.channelId}\nGuild: ${message.guildId}\nAuthor: ${inspect(author)}`, { broadcast: true }); - return; - } - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author.id); - const { resolver } = this.client; - const settings = await guild.settings(); - const { linkfilter: setting } = settings; - const { bypass, ignore, actions, silent, enabled, blacklist, whitelist, whitelistMode, greylist } = setting; - if (!enabled || !member) - return; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'link', permissions: missing }); - return; - } - - const msg = edited || message; - if (!msg.content) - return; - const content = msg.content.split('').join(''); // Copy the string... - let matches = content.match(this.regex.linkRegG); - let removedWhitespace = false; - if (!matches) - { - matches = content.replace(/\s/u, '').match(this.regex.linkRegG); - removedWhitespace = true; - } - - if (!matches) - return; - let remove = false; - const filterResult: FilterResult = {}; - let log = `${guild.name} Link filter debug:`; - - for (const match of matches) - { - let domain = match.match(this.regex.linkReg)!.groups?.domain; - if (!domain) - continue; - domain = domain.toLowerCase(); - // Invites are filtered separately - if (domain === 'discord.gg') - continue; - log += `\nMatched link ${match}: `; - - const predicate = (dom: string) => dom.toLowerCase().includes(domain!) || domain!.includes(dom.toLowerCase()); - - if (blacklist.some(predicate)) - { - log += 'in blacklist'; - filterResult.match = domain; - filterResult.matcher = 'link blacklist'; - remove = true; - break; - } - else if (greylist.some(predicate)) - { - log += 'in greylist'; - filterResult.match = domain; - filterResult.matcher = 'link greylist'; - remove = true; - break; - } - else if (whitelistMode) - { - if (whitelist.some(predicate)) - { - log += 'in whitelist'; - continue; - } - - const parts = domain.split('.'); - const validTld = this.topLevelDomains.find(parts[parts.length - 1]); - // console.log(parts, validTld); - if (!validTld) - continue; - const valid = await resolver.validateDomain(domain); - - if (removedWhitespace && !match.includes(`${domain}/`)) - continue; - - if (!valid) - { - // eslint-disable-next-line max-depth - if (match.includes(`${domain}/`)) - this.client.emit('linkFilterWarn', { guild, message: guild.format('LINKFILTER_WARN', { domain, link: match }) }); - continue; - } - - filterResult.match = domain; - filterResult.matcher = 'link whitelist'; - remove = true; - break; - } - } - - log += `\nFilter result: ${inspect(filterResult)}\nRemove: ${remove}`; - if (!remove) - return; - msg.filtered = filterResult; - filterResult.filter = 'link'; - - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel as TextChannel, guild.format('L_FILTER_DELETE', { user: author.id }), null, 'linkFilter'); - // const res = await msg.formattedRespond(`L_FILTER_DELETE`, { params: { user: author.id } }); - // if (res) res.delete({ timeout: 10000 }); - if (res) - setTimeout(() => - { - res.delete?.().catch(() => - { /**/ }); - }, 10000); - } - - this.client.rateLimiter.queueDelete(msg.channel as TextChannel, msg).catch(() => null); - if (actions.length) - { - let action = actions.find((act) => - { - return (act.trigger as string).includes(filterResult.match!); - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === filterResult.matcher!.split(' ')[1]; - }); - if (!action) - action = actions.find((act) => - { - return act.trigger === 'generic'; - }); - - if (action) - { - log += '\nSanctioned'; - await this._moderate(action, guild, channel as TextChannel, member, guild.format('L_FILTER_ACTION', { domain: filterResult.match }), filterResult); - } - } - this.logger.debug(log); - } - - async filterInvites (message: ExtendedMessage, edited: ExtendedMessage) - { - const { author, guildWrapper: guild } = message; - let channel = message.channel as TextChannel; - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author.id); - const settings = await guild.settings(); - const { invitefilter: setting } = settings; - const { bypass = [], ignore = [], actions, silent, enabled, whitelist = [] } = setting; - if (!enabled || !member) - return; - const roles = member.roles.cache.map((r) => r.id) || []; - - if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'invite', permissions: missing }); - return; - } - - const msg = edited || message; - const { content } = msg; - if (!content) - return; - - const match = content.match(this.regex.invite); - if (!match) - return; - - const invite = await this.client.fetchInvite(match.groups!.code).catch(() => null); - const result = await guild.checkInvite(match.groups!.code) || invite?.guild?.id === guild.id; - if (invite && invite.guild && whitelist.includes(invite.guild.id) || result) - return; - - if (!result) - { // Doesn't resolve to the origin server - let action = null; - if (actions.length) - [ action ] = actions; - - msg.filtered = { - match: match[0], - matcher: 'invites', - filter: 'invite' - }; - if (!action) - return this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); // msg.delete(); - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel, guild.format('I_FILTER_DELETE', { user: author.id }), null, 'inviteFilter'); - // if (res) res.delete({ timeout: 10000 }); - if (res) - setTimeout(() => - { - res.delete().catch(() => - { /**/ }); - }, 10000); - } - // msg.filtered.sactioned = true; - this.client.rateLimiter.queueDelete(channel, msg); - await this._moderate(action, guild, channel, member, guild.format('I_FILTER_ACTION'), msg.filtered); - } - } - - async filterMentions (message: ExtendedMessage) - { - const { author, guildWrapper: guild } = message; - let channel = message.channel as TextChannel; - if (!channel) - return; // Idk how or why but the channel is sometimes null? I have absolutely no clue how this could ever be the case - if (channel.partial) - channel = await channel.fetch(); - if (!guild || author.bot || message.filtered) - return; - - const member = await guild.memberWrapper(message.author); - const settings = await guild.settings(); - const { mentionfilter: setting } = settings; - const { bypass, ignore, enabled, silent, unique, limit, actions } = setting; - const roles = member?.roles.cache.map((r) => r.id) || []; - - if (!member || !enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) - return; - - const perms = channel.permissionsFor(this.client.user!); - const missing = perms?.missing('ManageMessages') || []; - if (missing.length) - { - this.client.emit('filterMissingPermissions', { channel, guild, filter: 'mention', permissions: missing }); - return; - } - - const { content } = message; - if (!content) - return; - - const matches = content.match(this.regex.mentionG); - if (!matches) - return; - - let ids = matches.map((match) => match.match(this.regex.mention)?.groups!.id).filter(Boolean) as string[]; - if (unique) - { - const set = new Set(ids); - ids = [ ...set ]; - } - - if (ids.length < limit) - return; - if (!silent && perms?.has('SendMessages')) - { - const res = await this.client.rateLimiter.limitSend(channel, guild.format('M_FILTER_DELETE', { user: author.id }), null, 'mentionFilter'); - if (res) - setTimeout(() => - { - res.delete().catch(() => - { /**/ }); - }, 10000); - } - - this.client.rateLimiter.queueDelete(channel, message).catch(() => null); - const filterResult = { - filter: 'mention', - amount: ids.length - }; - message.filtered = filterResult; - - if (actions.length) - { - - let action = actions.find((act) => - { - return (act.trigger as number) <= ids.length; - }); - - if (!action) - action = actions.find((act) => - { - return act.trigger === 'generic'; - }); - - if (!action) - return; - - await this._moderate(action, guild, channel, member, guild.format('M_FILTER_ACTION'), filterResult); - } - } - - async raidProtection (_member: ExtendedGuildMember) - { - // - } - +import { APIEmbed, APIEmbedField, ButtonComponentData, ButtonStyle, ComponentType, Interaction, MessageCreateOptions, PermissionsString, TextChannel } from 'discord.js'; +import { Ban, Kick, Mute, Softban, Warn } from '../../infractions/index.js'; +import Observer from '../../interfaces/Observer.js'; +import Initialisable from '../../interfaces/Initialisable.js'; +import DiscordClient from '../../DiscordClient.js'; +import BinaryTree from '../../../utilities/BinaryTree.js'; +import Util from '../../../utilities/Util.js'; +import { inspect } from 'util'; +import { FilterUtil } from '../../../utilities/index.js'; +import { FilterResult, Nullable } from '../../../../@types/Utils.js'; +import { ExtendedGuildMember, ExtendedMessage, FormatParams, SettingAction } from '../../../../@types/Client.js'; +import { stripIndents } from 'common-tags'; +import { ZeroWidthChar } from '../../../constants/Constants.js'; +import Infraction from '../../interfaces/Infraction.js'; +import InteractionWrapper from '../wrappers/InteractionWrapper.js'; +import InvokerWrapper from '../wrappers/InvokerWrapper.js'; +import GuildWrapper from '../wrappers/GuildWrapper.js'; +import MemberWrapper from '../wrappers/MemberWrapper.js'; + + +const CONSTANTS: { + Infractions: { [key: string]: typeof Infraction }, + ButtonStyles: { [key: string]: ButtonStyle.Danger }, + Permissions: {[key: string]: PermissionsString} +} = { + Infractions: { + WARN: Warn, + MUTE: Mute, + KICK: Kick, + SOFTBAN: Softban, + BAN: Ban + }, + ButtonStyles: { + BAN: ButtonStyle.Danger, + }, + Permissions: { + WARN: 'KickMembers', + MUTE: 'ModerateMembers', + KICK: 'KickMembers', + SOFTBAN: 'KickMembers', + BAN: 'BanMembers', + DELETE: 'ManageMessages' + } +}; + +export default class AutoModeration extends Observer implements Initialisable +{ + regex: { invite: RegExp; linkRegG: RegExp; linkReg: RegExp; mention: RegExp; mentionG: RegExp; }; + topLevelDomains!: BinaryTree; + executing: { [key: string]: string[] }; + constructor (client: DiscordClient) + { + super(client, { + name: 'autoModeration', + priority: 1, + disabled: false + }); + + this.hooks = [ + [ 'messageCreate', this.filterWords.bind(this) ], + [ 'messageUpdate', this.filterWords.bind(this) ], + [ 'messageCreate', this.filterInvites.bind(this) ], + [ 'messageUpdate', this.filterInvites.bind(this) ], + [ 'messageCreate', this.flagMessages.bind(this) ], + [ 'messageUpdate', this.flagMessages.bind(this) ], + [ 'messageCreate', this.filterLinks.bind(this) ], + [ 'messageUpdate', this.filterLinks.bind(this) ], + [ 'messageCreate', this.filterMentions.bind(this) ], + [ 'interactionCreate', this.flagAction.bind(this) ] + ]; + + this.executing = {}; + + this.regex = { + invite: /((discord\s*\.?\s*gg\s*)|discord(app)?\.com\/invite)\/\s?(?[a-z0-9]+)/iu, + 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, + 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, + mention: /<@!?(?[0-9]{18,22})>/u, + mentionG: /<@!?(?[0-9]{18,22})>/gu, + }; + } + + async initialise () + { + // Fetch a list of TLDs from iana + const tldList = await this.client.managerEval(` + (() => { + return ClientUtils.fetchTlds() + })() + `).catch(this.logger.error.bind(this.logger)) as string[] | undefined; + + if (!tldList) + throw Util.fatal('Failed to initialise automod'); + const middlePoint = Math.floor(tldList.length / 2); + const [ midEntry ] = tldList.splice(middlePoint, 1); + tldList.splice(0, 0, midEntry); + this.topLevelDomains = new BinaryTree(this.client, tldList); + this.topLevelDomains.add('onion'); + } + + async _moderate ( + action: SettingAction, guild: GuildWrapper, channel: TextChannel, + member: MemberWrapper, reason: string, filterResult: FilterResult, moderator?: MemberWrapper + ): Promise + { + // 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 false; + this.executing[filterResult.filter!].push(member.id); + + // Setting this true initially and negate if it fails, otherwise it won't show up as sanctioned in the msg logs + filterResult.sanctioned = true; + const InfractionClass = CONSTANTS.Infractions[action.type]; + const executor = moderator ?? await guild.memberWrapper(this.client.user!); + if (!executor) + throw new Error('Missing executor??'); + const result = await this.client.moderation.handleAutomod(InfractionClass, member, { + guild, + channel, + executor, + reason, + duration: action.duration ? action.duration * 1000 : null, + points: action.points, + expiration: action.expiration, + silent: false, + force: false, + prune: action.prune, + data: { + automoderation: filterResult + } + }).catch(this.logger.error.bind(this.logger)); + filterResult.sanctioned = !result?.error; + + await Util.wait(5000); + this.executing[filterResult.filter!].splice(this.executing[filterResult.filter!].indexOf(member.id), 1); + return !result?.error; + } + + async filterWords (message: ExtendedMessage, edited: ExtendedMessage) + { + const { guildWrapper: guild, author } = message; + let channel = message.channel as TextChannel; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author); + if (!member) + return; + const settings = await guild.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; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'word', permissions: missing }); + 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) + { + const error = err as Error; + this.logger.error(`Error in message filtering:\n${error.stack}\n${msg.cleanContent}`); + return; + } + log += `\nNormalised: ${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 + let filterResult: Partial> = { filter: 'word', match: null, matched: false, matcher: null, _matcher: null }; + 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) + { + const result = FilterUtil.filterExplicit(words, explicit); + if (result) + { + log += `\nMessage matched with "${result.match}" in the explicit list.\nFull content: ${content}`; + filterResult = result; + } + } + + // 3. Filter regex + if (regex.length && !filterResult.matched) + { + const result = FilterUtil.filterRegex(content, regex, whitelist); + if (result) + { + log += `\nMessage matched with "${result.matcher}" in the regex list.\nMatch: ${result.raw}, Full word: ${result.match}\nFull content: ${content}`; + filterResult = result; + } + } + + // 4. Filter fuzzy + if (fuzzy.length && !filterResult.matched) + { + const result = FilterUtil.filterFuzzy(words, fuzzy, whitelist); + if (result) + { + filterResult = result; + log += `\nMessage matched with "${result._matcher}" in fuzzy.\nMatched word: ${result.match}\nFull content: ${content}\nSimilarity: ${result.sim}\nThreshold: ${result.threshold}`; + } + } + + // 5. Remove message, inline response and add a reason to msg object + if (!filterResult.matched) + return; + msg.filtered = filterResult as FilterResult; + filterResult.filter = 'word'; + log += `\nFilter result: ${inspect(filterResult)}`; + + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(msg.channel as TextChannel, guild.format('W_FILTER_DELETE', { user: author.id }), null, 'wordFilter').catch(() => null); + // const res = await msg.formattedRespond('W_FILTER_DELETE', { params: { user: author.id } }); + // if (res) res.delete({ timeout: 10000 }).catch(catcher(240)); + if (res) + setTimeout(() => + { + res?.delete?.().catch(() => + { /**/ }); + }, 10000); + } + this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); + + // 6. Automated actions + if (actions.length) + { + let action = actions.find((act) => + { + return (act.trigger as string).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) + { + log += '\nSanctioned'; + await this._moderate(action, guild, channel, member, guild.format('W_FILTER_ACTION'), filterResult as FilterResult); + } + } + this.logger.debug(`${guild.name} WF DEBUG: ${log}`); + } + + async flagMessages (message: ExtendedMessage, edited: ExtendedMessage) + { + const { guild, author, guildWrapper: wrapper } = message; + let { channel } = message; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot) + return; + + const member = message.member || await guild.members.fetch(author.id).catch(() => null); + const settings = await wrapper.settings(); + const { wordwatcher: setting } = settings; + const { words, regex, bypass, ignore, channel: _logChannel, actions } = setting; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (!_logChannel || words.length === 0 || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const logChannel = await wrapper.resolveChannel(_logChannel); + const msg = edited || message; + if (!msg.content || !logChannel) + return; + let content = null; + try + { + content = FilterUtil.normalise(msg.cleanContent); + } + catch (err) + { + const error = err as Error; + this.logger.error(`Error in message flag:\n${error.stack}\n${msg.cleanContent}`); + return; + } + let match: RegExpMatchArray | string | null = null; + + for (const reg of regex) + { + // match = content.match(new RegExp(`(?:^|\\s)(${reg})`, 'iu')); + match = content.match(new RegExp(reg, 'iu')); + if (match) + { + [ match ] = match; + break; + } + } + + if (!match) + for (const word of words) + { + if (!content.includes(word)) + continue; + match = content.substring(content.indexOf(word), word.length); + break; + } + + if (!match) + return; + + const context = channel.messages.cache.sort((m1, m2) => m2.createdTimestamp - m1.createdTimestamp).first(5); + const embed: APIEmbed = { + title: `⚠️ Word trigger in **#${channel.name}**`, + description: stripIndents` + **[Jump to message](${msg.url})** + `, // ** User:** <@${ author.id }> + color: 15120384, + fields: context.reverse().reduce((acc, val) => + { + const text = val.content.length ? Util.escapeMarkdown(val.content).replace(match as string, '**__$&__**') : '**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: ZeroWidthChar, + value: '...' + text.substring(1013, 2034) + }); + return acc; + }, [] as APIEmbedField[]) + }; + + const components: ButtonComponentData[] = []; + for (const action of actions) + { + components.push({ + type: ComponentType.Button, + label: action.type, + // eslint-disable-next-line camelcase + customId: `WORDWATCHER_${action.trigger}`, + style: CONSTANTS.ButtonStyles[action.type] || ButtonStyle.Primary + }); + } + + const actionRow = components.length ? [{ + type: ComponentType.ActionRow, + components + }] : null; + const opts: MessageCreateOptions = { + embeds: [ embed ], + components: [] + }; + if (actionRow) + opts.components = actionRow; + const sent = await logChannel.send(opts).catch((err) => + { + this.logger.error('Error in message flag:\n' + err.stack); + }); + + // Only insert if actions are defined + if (actionRow && sent) + await this.client.storageManager.mongodb.wordwatcher.insertOne({ + message: sent.id, + target: msg.id, + channel: msg.channel.id, + timestamp: Date.now() + }); + } + + async flagAction (interaction: Interaction) + { + // console.log(interaction); + if (!interaction.isButton() || !interaction.inGuild()) + return; + const { message, customId } = interaction; + if (!customId.startsWith('WORDWATCHER_')) + return; + const { components } = message; + const [ , actionType ] = customId.split('_'); + const { permissions } = this.client; + + const guild = this.client.getGuildWrapper(interaction.guildId); + if (!guild) + return; + const moderator = await guild.memberWrapper(interaction.user.id); + if (!moderator) + return; + const settings = await guild.settings(); + const { wordwatcher } = settings; + const { actions } = wordwatcher; + + await interaction.deferUpdate(); + const fail = (index: string, opts?: FormatParams) => + { + this.client.emit('wordWatcherError', { + warning: true, + guild, + message: guild.format(index, opts) + }); + (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = ButtonStyle.Danger; + return message.edit({ components }); + }; + + // TODO: finish perm checks, need to figure out a workaround for `command:delete` + // actionType === 'DELETE' ? + // { error: false } : + const wrapper = new InteractionWrapper(this.client, interaction); + const invoker = new InvokerWrapper(this.client, wrapper); + const { inhibitor, error, params } + = await permissions.execute( + invoker, + { resolveable: `command:${actionType.toLowerCase()}`, memberPermissions: [] }, + [ CONSTANTS.Permissions[actionType] ] + ); + + if (error) + { + const missing = moderator.permissions.missing('ManageMessages'); + if (params.missing !== 'command:delete') + return fail(inhibitor.index, { command: actionType, missing: params.missing }); + else if (missing.length) + return fail(inhibitor.index, { missing: missing.join(', ') }); + } + + const log = await this.client.storageManager.mongodb.wordwatcher.findOne({ + message: message.id + }); + if (!log) + return fail('WORDWATCHER_MISSING_LOG'); + + const action = actions.find((act) => act.trigger === actionType); + if (!action) + return fail('WORDWATCHER_MISSING_ACTION', { actionType }); + + const targetChannel = await guild.resolveChannel(log.channel).catch(() => null); + if (!targetChannel) + return fail('WORDWATCHER_MISSING_CHANNEL'); + + const filterObj = { + filter: 'wordwatcher', + action: action.type + }; + const msg = await targetChannel.messages.fetch(log.target).catch(() => null) as ExtendedMessage; + if (msg) + { + await msg.delete().catch(() => null); + msg.filtered = filterObj; + } + + await this.client.storageManager.mongodb.wordwatcher.deleteOne({ + message: message.id + }); + + let success = false; + if (action.type === 'DELETE') + { + success = true; + } + else + { + const member = await guild.memberWrapper(message.author); + if (member) + success = await this._moderate(action, guild, targetChannel, member, guild.format('WORDWATCHER_ACTION'), filterObj, moderator); + else + this.client.emit('wordWatcherError', { warning: true, guild, message: guild.format('WORDWATCHER_MISSING_MEMBER', { actionType }) }); + } + + (components[0].components.find((comp) => comp.customId === customId) as ButtonComponentData).style = success + ? ButtonStyle.Success : ButtonStyle.Secondary; + await message.edit({ components }).catch(() => null); + } + + // eslint-disable-next-line max-lines-per-function + async filterLinks (message: ExtendedMessage, edited: ExtendedMessage) + { + const { author, guildWrapper: guild, channel } = message; + if (!channel) + { + this.logger.warn(`Missing channel?\nChannelId: ${message.channelId}\nGuild: ${message.guildId}\nAuthor: ${inspect(author)}`, { broadcast: true }); + return; + } + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author.id); + const { resolver } = this.client; + const settings = await guild.settings(); + const { linkfilter: setting } = settings; + const { bypass, ignore, actions, silent, enabled, blacklist, whitelist, whitelistMode, greylist } = setting; + if (!enabled || !member) + return; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'link', permissions: missing }); + return; + } + + const msg = edited || message; + if (!msg.content) + return; + const content = msg.content.split('').join(''); // Copy the string... + let matches = content.match(this.regex.linkRegG); + let removedWhitespace = false; + if (!matches) + { + matches = content.replace(/\s/u, '').match(this.regex.linkRegG); + removedWhitespace = true; + } + + if (!matches) + return; + let remove = false; + const filterResult: FilterResult = {}; + let log = `${guild.name} Link filter debug:`; + + for (const match of matches) + { + let domain = match.match(this.regex.linkReg)!.groups?.domain; + if (!domain) + continue; + domain = domain.toLowerCase(); + // Invites are filtered separately + if (domain === 'discord.gg') + continue; + log += `\nMatched link ${match}: `; + + const predicate = (dom: string) => dom.toLowerCase().includes(domain!) || domain!.includes(dom.toLowerCase()); + + if (blacklist.some(predicate)) + { + log += 'in blacklist'; + filterResult.match = domain; + filterResult.matcher = 'link blacklist'; + remove = true; + break; + } + else if (greylist.some(predicate)) + { + log += 'in greylist'; + filterResult.match = domain; + filterResult.matcher = 'link greylist'; + remove = true; + break; + } + else if (whitelistMode) + { + if (whitelist.some(predicate)) + { + log += 'in whitelist'; + continue; + } + + const parts = domain.split('.'); + const validTld = this.topLevelDomains.find(parts[parts.length - 1]); + // console.log(parts, validTld); + if (!validTld) + continue; + const valid = await resolver.validateDomain(domain); + + if (removedWhitespace && !match.includes(`${domain}/`)) + continue; + + if (!valid) + { + // eslint-disable-next-line max-depth + if (match.includes(`${domain}/`)) + this.client.emit('linkFilterWarn', { guild, message: guild.format('LINKFILTER_WARN', { domain, link: match }) }); + continue; + } + + filterResult.match = domain; + filterResult.matcher = 'link whitelist'; + remove = true; + break; + } + } + + log += `\nFilter result: ${inspect(filterResult)}\nRemove: ${remove}`; + if (!remove) + return; + msg.filtered = filterResult; + filterResult.filter = 'link'; + + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel as TextChannel, guild.format('L_FILTER_DELETE', { user: author.id }), null, 'linkFilter'); + // const res = await msg.formattedRespond(`L_FILTER_DELETE`, { params: { user: author.id } }); + // if (res) res.delete({ timeout: 10000 }); + if (res) + setTimeout(() => + { + res.delete?.().catch(() => + { /**/ }); + }, 10000); + } + + this.client.rateLimiter.queueDelete(msg.channel as TextChannel, msg).catch(() => null); + if (actions.length) + { + let action = actions.find((act) => + { + return (act.trigger as string).includes(filterResult.match!); + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === filterResult.matcher!.split(' ')[1]; + }); + if (!action) + action = actions.find((act) => + { + return act.trigger === 'generic'; + }); + + if (action) + { + log += '\nSanctioned'; + await this._moderate(action, guild, channel as TextChannel, member, guild.format('L_FILTER_ACTION', { domain: filterResult.match }), filterResult); + } + } + this.logger.debug(log); + } + + async filterInvites (message: ExtendedMessage, edited: ExtendedMessage) + { + const { author, guildWrapper: guild } = message; + let channel = message.channel as TextChannel; + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author.id); + const settings = await guild.settings(); + const { invitefilter: setting } = settings; + const { bypass = [], ignore = [], actions, silent, enabled, whitelist = [] } = setting; + if (!enabled || !member) + return; + const roles = member.roles.cache.map((r) => r.id) || []; + + if (roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'invite', permissions: missing }); + return; + } + + const msg = edited || message; + const { content } = msg; + if (!content) + return; + + const match = content.match(this.regex.invite); + if (!match) + return; + + const invite = await this.client.fetchInvite(match.groups!.code).catch(() => null); + const result = await guild.checkInvite(match.groups!.code) || invite?.guild?.id === guild.id; + if (invite && invite.guild && whitelist.includes(invite.guild.id) || result) + return; + + if (!result) + { // Doesn't resolve to the origin server + let action = null; + if (actions.length) + [ action ] = actions; + + msg.filtered = { + match: match[0], + matcher: 'invites', + filter: 'invite' + }; + if (!action) + return this.client.rateLimiter.queueDelete(channel, msg).catch(() => null); // msg.delete(); + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel, guild.format('I_FILTER_DELETE', { user: author.id }), null, 'inviteFilter'); + // if (res) res.delete({ timeout: 10000 }); + if (res) + setTimeout(() => + { + res.delete().catch(() => + { /**/ }); + }, 10000); + } + // msg.filtered.sactioned = true; + this.client.rateLimiter.queueDelete(channel, msg); + await this._moderate(action, guild, channel, member, guild.format('I_FILTER_ACTION'), msg.filtered); + } + } + + async filterMentions (message: ExtendedMessage) + { + const { author, guildWrapper: guild } = message; + let channel = message.channel as TextChannel; + if (!channel) + return; // Idk how or why but the channel is sometimes null? I have absolutely no clue how this could ever be the case + if (channel.partial) + channel = await channel.fetch(); + if (!guild || author.bot || message.filtered) + return; + + const member = await guild.memberWrapper(message.author); + const settings = await guild.settings(); + const { mentionfilter: setting } = settings; + const { bypass, ignore, enabled, silent, unique, limit, actions } = setting; + const roles = member?.roles.cache.map((r) => r.id) || []; + + if (!member || !enabled || roles.some((r) => bypass.includes(r)) || ignore.includes(channel.id)) + return; + + const perms = channel.permissionsFor(this.client.user!); + const missing = perms?.missing('ManageMessages') || []; + if (missing.length) + { + this.client.emit('filterMissingPermissions', { channel, guild, filter: 'mention', permissions: missing }); + return; + } + + const { content } = message; + if (!content) + return; + + const matches = content.match(this.regex.mentionG); + if (!matches) + return; + + let ids = matches.map((match) => match.match(this.regex.mention)?.groups!.id).filter(Boolean) as string[]; + if (unique) + { + const set = new Set(ids); + ids = [ ...set ]; + } + + if (ids.length < limit) + return; + if (!silent && perms?.has('SendMessages')) + { + const res = await this.client.rateLimiter.limitSend(channel, guild.format('M_FILTER_DELETE', { user: author.id }), null, 'mentionFilter'); + if (res) + setTimeout(() => + { + res.delete().catch(() => + { /**/ }); + }, 10000); + } + + this.client.rateLimiter.queueDelete(channel, message).catch(() => null); + const filterResult = { + filter: 'mention', + amount: ids.length + }; + message.filtered = filterResult; + + if (actions.length) + { + + let action = actions.find((act) => + { + return (act.trigger as number) <= ids.length; + }); + + if (!action) + action = actions.find((act) => + { + return act.trigger === 'generic'; + }); + + if (!action) + return; + + await this._moderate(action, guild, channel, member, guild.format('M_FILTER_ACTION'), filterResult); + } + } + + async raidProtection (_member: ExtendedGuildMember) + { + // + } + } \ No newline at end of file diff --git a/src/middleware/Controller.ts b/src/middleware/Controller.ts index 8bb3452..0251e80 100644 --- a/src/middleware/Controller.ts +++ b/src/middleware/Controller.ts @@ -209,9 +209,9 @@ class Controller extends EventEmitter if (message._logger) return; // this.logger.debug(`New message from ${shard ? `${message._api ? 'api-' : ''}shard ${shard.id}`: 'manager'}: ${inspect(message)}`); - + if (message._mEval) - return this.eval(shard, { script: message._mEval, debug: message.debug || false }); + return this.eval(shard, { script: message.script!, debug: message.debug || false }); if (message._commands) return this.#slashCommandManager._handleMessage(message as CommandsDef); if (message._api) diff --git a/src/middleware/shard/Shard.ts b/src/middleware/shard/Shard.ts index 51b9362..c89ee71 100644 --- a/src/middleware/shard/Shard.ts +++ b/src/middleware/shard/Shard.ts @@ -55,7 +55,8 @@ class Shard extends EventEmitter this.#env = { ...process.env, - SHARDING_MANAGER: true, + SHARDING_MANAGER: true, // IMPORTANT, SHARD IPC WILL BREAK IF MISSING + SHARDING_MANAGER_MODE: 'process', // IMPORTANT, SHARD IPC WILL BREAK IF MISSING SHARD_ID: this.#id, SHARD_COUNT: options.totalShards, DISCORD_TOKEN: options.token @@ -170,7 +171,7 @@ class Shard extends EventEmitter // When killing the process, give it an opportonity to gracefully shut down (i.e. clean up DB connections etc) // It simply has to respond with a shutdown message to the shutdown event - kill () + kill () { if (this.#process) { @@ -208,7 +209,7 @@ class Shard extends EventEmitter return Promise.resolve(); } - awaitShutdown () + awaitShutdown () { this.#respawn = false; return new Promise((resolve) => @@ -292,7 +293,7 @@ class Shard extends EventEmitter } // eslint-disable-next-line @typescript-eslint/ban-types - eval (script: string | Function, context?: object): Promise + eval (script: string | Function, context?: object): Promise { // Stringify the script if it's a Function const _eval = typeof script === 'function' ? `(${script})(this, ${JSON.stringify(context)})` : script;