diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2021-10-12 20:27:37 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2021-10-12 20:27:37 -0400 |
commit | ba2d7b7db0a627234ed08de9d6bec8cb675404a7 (patch) | |
tree | 9ade9ed549b52eac3f2966a5cee5478267eca7c4 /src/lib/common | |
parent | cac6abf3efd563b83f8f0ce70ce4bcfa5ada1a27 (diff) | |
download | tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.tar.gz tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.tar.bz2 tanzanite-ba2d7b7db0a627234ed08de9d6bec8cb675404a7.zip |
revamp automod, refactoring, fixes
Diffstat (limited to 'src/lib/common')
-rw-r--r-- | src/lib/common/autoMod.ts | 236 | ||||
-rw-r--r-- | src/lib/common/moderation.ts | 181 |
2 files changed, 417 insertions, 0 deletions
diff --git a/src/lib/common/autoMod.ts b/src/lib/common/autoMod.ts new file mode 100644 index 0000000..10bccba --- /dev/null +++ b/src/lib/common/autoMod.ts @@ -0,0 +1,236 @@ +import { Formatters, MessageActionRow, MessageButton, MessageEmbed, TextChannel } from 'discord.js'; +import badLinksArray from '../../lib/badlinks'; +import badLinksSecretArray from '../../lib/badlinks-secret'; // I cannot make this public so just make a new file that export defaults an empty array +import badWords from '../../lib/badwords'; +import { BushButtonInteraction } from '../extensions/discord.js/BushButtonInteraction'; +import { BushMessage } from '../extensions/discord.js/BushMessage'; + +export class AutoMod { + private message: BushMessage; + + public constructor(message: BushMessage) { + this.message = message; + void this.handle(); + } + + private async handle(): Promise<void> { + if (this.message.channel.type === 'DM' || !this.message.guild) return; + if (!(await this.message.guild.hasFeature('automod'))) return; + + const customAutomodPhrases = (await this.message.guild.getSetting('autoModPhases')) ?? {}; + const badLinks: BadWords = {}; + const badLinksSecret: BadWords = {}; + + badLinksArray.forEach((link) => { + badLinks[link] = { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: 'malicious link' + }; + }); + badLinksSecretArray.forEach((link) => { + badLinks[link] = { + severity: Severity.PERM_MUTE, + ignoreSpaces: true, + ignoreCapitalization: true, + reason: 'malicious link' + }; + }); + + const result = { + ...this.checkWords(customAutomodPhrases), + ...this.checkWords((await this.message.guild.hasFeature('excludeDefaultAutomod')) ? {} : badWords), + ...this.checkWords( + (await this.message.guild.hasFeature('excludeAutomodScamLinks')) ? {} : { ...badLinks, ...badLinksSecret } + ) + }; + + if (Object.keys(result).length === 0) return; + + const highestOffence = Object.entries(result) + .map(([key, value]) => ({ word: key, ...value })) + .sort((a, b) => b.severity - a.severity)[0]; + + if (highestOffence.severity === undefined || highestOffence.severity === null) + void this.message.guild.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to find severity information for ${Formatters.inlineCode( + util.discord.escapeInlineCode(highestOffence.word) + )}`, + color: util.colors.error + } + ] + }); + else { + const color = this.punish(highestOffence); + void this.log(highestOffence, color, result); + } + } + + private checkWords(words: BadWords): BadWords { + if (Object.keys(words).length === 0) return {}; + + const matchedWords: BadWords = {}; + for (const word in words) { + const wordOptions = words[word]; + if (this.format(this.message.content, wordOptions) === this.format(word, wordOptions)) { + } + } + return matchedWords; + } + + private format(string: string, wordOptions: BadWordDetails) { + const temp = wordOptions.ignoreCapitalization ? string.toLowerCase() : string; + return wordOptions.ignoreSpaces ? temp.replace(/ /g, '') : temp; + } + + private punish(highestOffence: BadWordDetails & { word: string }) { + let color; + switch (highestOffence.severity) { + case Severity.DELETE: { + color = util.colors.lightGray; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + break; + } + case Severity.WARN: { + color = util.colors.yellow; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.warn({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}` + }); + break; + } + case Severity.TEMP_MUTE: { + color = util.colors.orange; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.mute({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}`, + duration: 900_000 // 15 minutes + }); + break; + } + case Severity.PERM_MUTE: { + color = util.colors.red; + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.message.member?.mute({ + moderator: this.message.guild!.me!, + reason: `[AutoMod] ${highestOffence.reason}`, + duration: 900_000 // 15 minutes + }); + break; + } + default: { + throw new Error('Invalid severity'); + } + } + + return color; + + async function deleteError(this: AutoMod, e: Error | any) { + this.message.guild?.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to delete triggered message.`, + fields: [{ name: 'Error', value: await util.codeblock(`${e.stack ?? e}`, 1024, 'js', true) }], + color: util.colors.error + } + ] + }); + } + } + + private async log(highestOffence: BadWordDetails & { word: string }, color: `#${string}`, offences: BadWords) { + void client.console.info( + 'autoMod', + `Severity <<${highestOffence.severity}>> action performed on <<${this.message.author.tag}>> (<<${ + this.message.author.id + }>>) in <<#${(this.message.channel as TextChannel).name}>> in <<${this.message.guild!.name}>>` + ); + + return await this.message.guild!.sendLogChannel('automod', { + embeds: [ + new MessageEmbed() + .setTitle(`[Severity ${highestOffence.severity}] Automod Action Performed`) + .setDescription( + `**User:** ${this.message.author} (${this.message.author.tag})\n**Sent From**: <#${ + this.message.channel.id + }> [Jump to context](${this.message.url})\n**Blacklisted Words:** ${util + .surroundArray(Object.keys(offences), '`') + .join(', ')}` + ) + .addField('Message Content', `${await util.codeblock(this.message.content, 1024)}`) + .setColor(color) + .setTimestamp() + ], + components: + highestOffence.severity >= 2 + ? [ + new MessageActionRow().addComponents( + new MessageButton() + .setStyle('DANGER') + .setLabel('Ban User') + .setCustomId(`automod;ban;${this.message.author.id};${highestOffence.reason}`) + ) + ] + : undefined + }); + } + + public static async handleInteraction(interaction: BushButtonInteraction) { + if (!interaction.memberPermissions?.has('BAN_MEMBERS')) + return interaction.reply({ + content: `${util.emojis.error} You are missing the **Ban Members** permission.`, + ephemeral: true + }); + const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';'); + switch (action) { + case 'ban': { + await interaction.deferReply(); + const result = await interaction.guild?.bushBan({ user: userId, reason, moderator: interaction.user.id }); + + if (result === 'success') + return interaction.reply({ + content: `${util.emojis.success} Successfully banned **${ + interaction.guild?.members.cache.get(userId)?.user.tag ?? userId + }**.`, + ephemeral: true + }); + else + return interaction.reply({ + content: `${util.emojis.error} Could not ban **${ + interaction.guild?.members.cache.get(userId)?.user.tag ?? userId + }**: \`${result}\` .`, + ephemeral: true + }); + } + } + } +} + +export const enum Severity { + /** Delete message */ + DELETE, + /** Delete message and warn user */ + WARN, + /** Delete message and mute user for 15 minutes */ + TEMP_MUTE, + /** Delete message and mute user permanently */ + PERM_MUTE +} + +interface BadWordDetails { + severity: Severity; + ignoreSpaces: boolean; + ignoreCapitalization: boolean; + reason: string; +} + +export interface BadWords { + [key: string]: BadWordDetails; +} diff --git a/src/lib/common/moderation.ts b/src/lib/common/moderation.ts new file mode 100644 index 0000000..4af6ec2 --- /dev/null +++ b/src/lib/common/moderation.ts @@ -0,0 +1,181 @@ +import { Snowflake } from 'discord-api-types'; +import { + ActivePunishment, + ActivePunishmentType, + BushGuildMember, + BushGuildMemberResolvable, + BushGuildResolvable, + Guild, + ModLog, + ModLogType +} from '..'; + +export class Moderation { + /** + * Checks if a moderator can perform a moderation action on another user. + * @param moderator - The person trying to perform the action. + * @param victim - The person getting punished. + * @param type - The type of punishment - used to format the response. + * @param checkModerator - Whether or not to check if the victim is a moderator. + */ + public static async permissionCheck( + moderator: BushGuildMember, + victim: BushGuildMember, + type: 'mute' | 'unmute' | 'warn' | 'kick' | 'ban' | 'unban' | 'add a punishment role to' | 'remove a punishment role from', + checkModerator = true, + force = false + ): Promise<true | string> { + if (force) return true; + + // If the victim is not in the guild anymore it will be undefined + if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; + + if (moderator.guild.id !== victim.guild.id) { + throw new Error('moderator and victim not in same guild'); + } + + const isOwner = moderator.guild.ownerId === moderator.id; + if (moderator.id === victim.id && !type.startsWith('un')) { + return `${util.emojis.error} You cannot ${type} yourself.`; + } + if ( + moderator.roles.highest.position <= victim.roles.highest.position && + !isOwner && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; + } + if ( + victim.roles.highest.position >= victim.guild.me!.roles.highest.position && + !(type.startsWith('un') && moderator.id === victim.id) + ) { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; + } + if (checkModerator && victim.permissions.has('MANAGE_MESSAGES') && !(type.startsWith('un') && moderator.id === victim.id)) { + if (await moderator.guild.hasFeature('modsCanPunishMods')) { + return true; + } else { + return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; + } + } + return true; + } + + public static async createModLogEntry( + options: { + type: ModLogType; + user: BushGuildMemberResolvable; + moderator: BushGuildMemberResolvable; + reason: string | undefined | null; + duration?: number; + guild: BushGuildResolvable; + pseudo?: boolean; + }, + getCaseNumber = false + ): Promise<{ log: ModLog | null; caseNum: number | null }> { + const user = (await util.resolveNonCachedUser(options.user))!.id; + const moderator = (await util.resolveNonCachedUser(options.moderator))!.id; + const guild = client.guilds.resolveId(options.guild)!; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const duration = options.duration || undefined; + + // If guild does not exist create it so the modlog can reference a guild. + await Guild.findOrCreate({ + where: { + id: guild + }, + defaults: { + id: guild + } + }); + + const modLogEntry = ModLog.build({ + type: options.type, + user, + moderator, + reason: options.reason, + duration: duration, + guild, + pseudo: options.pseudo ?? false + }); + const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { + await util.handleError('createModLogEntry', e); + return null; + }); + + if (!getCaseNumber) return { log: saveResult, caseNum: null }; + + const caseNum = (await ModLog.findAll({ where: { type: options.type, user: user, guild: guild, hidden: 'false' } })) + ?.length; + return { log: saveResult, caseNum }; + } + + public static async createPunishmentEntry(options: { + type: 'mute' | 'ban' | 'role' | 'block'; + user: BushGuildMemberResolvable; + duration: number | undefined; + guild: BushGuildResolvable; + modlog: string; + extraInfo?: Snowflake; + }): Promise<ActivePunishment | null> { + const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; + const user = (await util.resolveNonCachedUser(options.user))!.id; + const guild = client.guilds.resolveId(options.guild)!; + const type = this.#findTypeEnum(options.type)!; + + const entry = ActivePunishment.build( + options.extraInfo + ? { user, type, guild, expires, modlog: options.modlog, extraInfo: options.extraInfo } + : { user, type, guild, expires, modlog: options.modlog } + ); + return await entry.save().catch(async (e) => { + await util.handleError('createPunishmentEntry', e); + return null; + }); + } + + public static async removePunishmentEntry(options: { + type: 'mute' | 'ban' | 'role' | 'block'; + user: BushGuildMemberResolvable; + guild: BushGuildResolvable; + extraInfo?: Snowflake; + }): Promise<boolean> { + const user = await util.resolveNonCachedUser(options.user); + const guild = client.guilds.resolveId(options.guild); + const type = this.#findTypeEnum(options.type); + + if (!user || !guild) return false; + + let success = true; + + const entries = await ActivePunishment.findAll({ + // finding all cases of a certain type incase there were duplicates or something + where: options.extraInfo + ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } + : { user: user.id, guild: guild, type } + }).catch(async (e) => { + await util.handleError('removePunishmentEntry', e); + success = false; + }); + if (entries) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + entries.forEach(async (entry) => { + await entry.destroy().catch(async (e) => { + await util.handleError('removePunishmentEntry', e); + }); + success = false; + }); + } + return success; + } + + static #findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { + const typeMap = { + ['mute']: ActivePunishmentType.MUTE, + ['ban']: ActivePunishmentType.BAN, + ['role']: ActivePunishmentType.ROLE, + ['block']: ActivePunishmentType.BLOCK + }; + return typeMap[type]; + } +} |