diff options
Diffstat (limited to 'lib/automod')
-rw-r--r-- | lib/automod/AutomodShared.ts | 310 | ||||
-rw-r--r-- | lib/automod/MemberAutomod.ts | 72 | ||||
-rw-r--r-- | lib/automod/MessageAutomod.ts | 286 | ||||
-rw-r--r-- | lib/automod/PresenceAutomod.ts | 85 |
4 files changed, 753 insertions, 0 deletions
diff --git a/lib/automod/AutomodShared.ts b/lib/automod/AutomodShared.ts new file mode 100644 index 0000000..5d031d0 --- /dev/null +++ b/lib/automod/AutomodShared.ts @@ -0,0 +1,310 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + GuildMember, + Message, + PermissionFlagsBits, + Snowflake +} from 'discord.js'; +import UnmuteCommand from '../../src/commands/moderation/unmute.js'; +import * as Moderation from '../common/Moderation.js'; +import { unmuteResponse } from '../extensions/discord.js/ExtendedGuildMember.js'; +import { colors, emojis } from '../utils/BushConstants.js'; +import * as Format from '../utils/Format.js'; + +/** + * Handles shared auto moderation functionality. + */ +export abstract class Automod { + /** + * Whether or not a punishment has already been given to the user + */ + protected punished = false; + + /** + * @param member The guild member that the automod is checking + */ + protected constructor(protected readonly member: GuildMember) {} + + /** + * The user + */ + protected get user() { + return this.member.user; + } + + /** + * The client instance + */ + protected get client() { + return this.member.client; + } + + /** + * The guild member that the automod is checking + */ + protected get guild() { + return this.member.guild; + } + + /** + * Whether or not the member should be immune to auto moderation + */ + protected get isImmune() { + if (this.member.user.isOwner()) return true; + if (this.member.guild.ownerId === this.member.id) return true; + if (this.member.permissions.has('Administrator')) return true; + + return false; + } + + protected buttons(userId: Snowflake, reason: string, undo = true): ActionRowBuilder<ButtonBuilder> { + const row = new ActionRowBuilder<ButtonBuilder>().addComponents([ + new ButtonBuilder({ + style: ButtonStyle.Danger, + label: 'Ban User', + customId: `automod;ban;${userId};${reason}` + }) + ]); + + if (undo) { + row.addComponents( + new ButtonBuilder({ + style: ButtonStyle.Success, + label: 'Unmute User', + customId: `automod;unmute;${userId}` + }) + ); + } + + return row; + } + + protected logColor(severity: Severity) { + switch (severity) { + case Severity.DELETE: + return colors.lightGray; + case Severity.WARN: + return colors.yellow; + case Severity.TEMP_MUTE: + return colors.orange; + case Severity.PERM_MUTE: + return colors.red; + } + throw new Error(`Unknown severity: ${severity}`); + } + + /** + * Checks if any of the words provided are in the message + * @param words The words to check for + * @returns The blacklisted words found in the message + */ + protected checkWords(words: BadWordDetails[], str: string): BadWordDetails[] { + if (words.length === 0) return []; + + const matchedWords: BadWordDetails[] = []; + for (const word of words) { + if (word.regex) { + if (new RegExp(word.match).test(this.format(word.match, word))) { + matchedWords.push(word); + } + } else { + if (this.format(str, word).includes(this.format(word.match, word))) { + matchedWords.push(word); + } + } + } + return matchedWords; + } + + /** + * Format a string according to the word options + * @param string The string to format + * @param wordOptions The word options to format with + * @returns The formatted string + */ + protected format(string: string, wordOptions: BadWordDetails) { + const temp = wordOptions.ignoreCapitalization ? string.toLowerCase() : string; + return wordOptions.ignoreSpaces ? temp.replace(/ /g, '') : temp; + } + + /** + * Handles the auto moderation + */ + protected abstract handle(): Promise<void>; +} + +/** + * Handles the ban button in the automod log. + * @param interaction The button interaction. + */ +export async function handleAutomodInteraction(interaction: ButtonInteraction) { + if (!interaction.memberPermissions?.has(PermissionFlagsBits.BanMembers)) + return interaction.reply({ + content: `${emojis.error} You are missing the **Ban Members** permission.`, + ephemeral: true + }); + const [action, userId, reason] = interaction.customId.replace('automod;', '').split(';') as ['ban' | 'unmute', string, string]; + + if (!(['ban', 'unmute'] as const).includes(action)) throw new TypeError(`Invalid automod button action: ${action}`); + + const victim = await interaction.guild!.members.fetch(userId).catch(() => null); + const moderator = + interaction.member instanceof GuildMember ? interaction.member : await interaction.guild!.members.fetch(interaction.user.id); + + switch (action) { + case 'ban': { + if (!interaction.guild?.members.me?.permissions.has('BanMembers')) + return interaction.reply({ + content: `${emojis.error} I do not have permission to ${action} members.`, + ephemeral: true + }); + + const check = victim ? await Moderation.permissionCheck(moderator, victim, 'ban', true) : true; + if (check !== true) return interaction.reply({ content: check, ephemeral: true }); + + const result = await interaction.guild?.bushBan({ + user: userId, + reason, + moderator: interaction.user.id, + evidence: (interaction.message as Message).url ?? undefined + }); + + const victimUserFormatted = (await interaction.client.utils.resolveNonCachedUser(userId))?.tag ?? userId; + + const content = (() => { + if (result === unmuteResponse.SUCCESS) { + return `${emojis.success} Successfully banned ${Format.input(victimUserFormatted)}.`; + } else if (result === unmuteResponse.DM_ERROR) { + return `${emojis.warn} Banned ${Format.input(victimUserFormatted)} however I could not send them a dm.`; + } else { + return `${emojis.error} Could not ban ${Format.input(victimUserFormatted)}: \`${result}\` .`; + } + })(); + + return interaction.reply({ + content: content, + ephemeral: true + }); + } + + case 'unmute': { + if (!victim) + return interaction.reply({ + content: `${emojis.error} Cannot find member, they may have left the server.`, + ephemeral: true + }); + + if (!interaction.guild) + return interaction.reply({ + content: `${emojis.error} This is weird, I don't seem to be in the server...`, + ephemeral: true + }); + + const check = await Moderation.permissionCheck(moderator, victim, 'unmute', true); + if (check !== true) return interaction.reply({ content: check, ephemeral: true }); + + const check2 = await Moderation.checkMutePermissions(interaction.guild); + if (check2 !== true) return interaction.reply({ content: UnmuteCommand.formatCode('/', victim!, check2), ephemeral: true }); + + const result = await victim.bushUnmute({ + reason, + moderator: interaction.member as GuildMember, + evidence: (interaction.message as Message).url ?? undefined + }); + + const victimUserFormatted = victim.user.tag; + + const content = (() => { + if (result === unmuteResponse.SUCCESS) { + return `${emojis.success} Successfully unmuted ${Format.input(victimUserFormatted)}.`; + } else if (result === unmuteResponse.DM_ERROR) { + return `${emojis.warn} Unmuted ${Format.input(victimUserFormatted)} however I could not send them a dm.`; + } else { + return `${emojis.error} Could not unmute ${Format.input(victimUserFormatted)}: \`${result}\` .`; + } + })(); + + return interaction.reply({ + content: content, + ephemeral: true + }); + } + } +} + +/** + * The severity of the blacklisted word + */ +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 +} + +/** + * Details about a blacklisted word + */ +export interface BadWordDetails { + /** + * The word that is blacklisted + */ + match: string; + + /** + * The severity of the word + */ + severity: Severity | 1 | 2 | 3; + + /** + * Whether or not to ignore spaces when checking for the word + */ + ignoreSpaces: boolean; + + /** + * Whether or not to ignore case when checking for the word + */ + ignoreCapitalization: boolean; + + /** + * The reason that this word is blacklisted (used for the punishment reason) + */ + reason: string; + + /** + * Whether or not the word is regex + * @default false + */ + regex: boolean; + + /** + * Whether to also check a user's status and username for the phrase + * @default false + */ + userInfo: boolean; +} + +/** + * Blacklisted words mapped to their details + */ +export interface BadWords { + [category: string]: BadWordDetails[]; +} diff --git a/lib/automod/MemberAutomod.ts b/lib/automod/MemberAutomod.ts new file mode 100644 index 0000000..6f71457 --- /dev/null +++ b/lib/automod/MemberAutomod.ts @@ -0,0 +1,72 @@ +import { stripIndent } from '#tags'; +import { EmbedBuilder, GuildMember } from 'discord.js'; +import { Automod, BadWordDetails } from './AutomodShared.js'; + +export class MemberAutomod extends Automod { + /** + * @param member The member that the automod is checking + */ + public constructor(member: GuildMember) { + super(member); + + if (member.id === member.client.user?.id) return; + + void this.handle(); + } + + protected async handle(): Promise<void> { + if (this.member.user.bot) return; + + const badWordsRaw = Object.values(this.client.utils.getShared('badWords')).flat(); + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + + const phrases = [...badWordsRaw, ...customAutomodPhrases].filter((p) => p.userInfo); + + const result: BadWordDetails[] = []; + + const str = `${this.member.user.username}${this.member.nickname ? `\n${this.member.nickname}` : ''}`; + const check = this.checkWords(phrases, str); + if (check.length > 0) { + result.push(...check); + } + + if (result.length > 0) { + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + await this.logMessage(highestOffense, result, str); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[], str: string) { + void this.client.console.info( + 'MemberAutomod', + `Detected a severity <<${highestOffense.severity}>> automod phrase in <<${this.user.tag}>>'s (<<${this.user.id}>>) username or nickname in <<${this.guild.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automoderated User Info Detected`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields({ + name: 'Info', + value: `${await this.client.utils.codeblock(str, 1024)}` + }) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: [this.buttons(this.user.id, highestOffense.reason, false)] + }); + } +} diff --git a/lib/automod/MessageAutomod.ts b/lib/automod/MessageAutomod.ts new file mode 100644 index 0000000..9673adf --- /dev/null +++ b/lib/automod/MessageAutomod.ts @@ -0,0 +1,286 @@ +import { stripIndent } from '#tags'; +import assert from 'assert/strict'; +import chalk from 'chalk'; +import { EmbedBuilder, GuildTextBasedChannel, PermissionFlagsBits, type Message } from 'discord.js'; +import { colors } from '../utils/BushConstants.js'; +import { format, formatError } from '../utils/BushUtils.js'; +import { Automod, BadWordDetails, Severity } from './AutomodShared.js'; + +/** + * Handles message auto moderation functionality. + */ +export class MessageAutomod extends Automod { + /** + * @param message The message to check and potentially perform automod actions on + */ + public constructor(private readonly message: Message) { + assert(message.member); + super(message.member); + + if (message.author.id === message.client.user?.id) return; + void this.handle(); + } + + /** + * Handles the auto moderation + */ + protected async handle() { + if (!this.message.inGuild()) return; + if (!(await this.guild.hasFeature('automod'))) return; + if (this.user.bot) return; + if (!this.message.member) return; + + traditional: { + if (this.isImmune) break traditional; + const badLinksArray = this.client.utils.getShared('badLinks'); + const badLinksSecretArray = this.client.utils.getShared('badLinksSecret'); + const badWordsRaw = this.client.utils.getShared('badWords'); + + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + const uniqueLinks = [...new Set([...badLinksArray, ...badLinksSecretArray])]; + + const badLinks: BadWordDetails[] = uniqueLinks.map((link) => ({ + match: link, + severity: Severity.PERM_MUTE, + ignoreSpaces: false, + ignoreCapitalization: true, + reason: 'malicious link', + regex: false, + userInfo: false + })); + + const parsedBadWords = Object.values(badWordsRaw).flat(); + + const result = this.checkWords( + [ + ...customAutomodPhrases, + ...((await this.guild.hasFeature('excludeDefaultAutomod')) ? [] : parsedBadWords), + ...((await this.guild.hasFeature('excludeAutomodScamLinks')) ? [] : badLinks) + ], + this.message.content + ); + + if (result.length === 0) break traditional; + + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + + if (highestOffense.severity === undefined || highestOffense.severity === null) { + void this.guild.sendLogChannel('error', { + embeds: [ + { + title: 'AutoMod Error', + description: `Unable to find severity information for ${format.inlineCode(highestOffense.match)}`, + color: colors.error + } + ] + }); + } else { + this.punish(highestOffense); + void this.logMessage(highestOffense, result); + } + } + + other: { + if (this.isImmune) break other; + if (!this.punished && (await this.guild.hasFeature('delScamMentions'))) void this.checkScamMentions(); + } + + if (!this.punished && (await this.guild.hasFeature('perspectiveApi'))) void this.checkPerspectiveApi(); + } + + /** + * If the message contains '@everyone' or '@here' and it contains a common scam phrase, it will be deleted + * @returns + */ + protected async checkScamMentions() { + const includes = (c: string) => this.message.content.toLocaleLowerCase().includes(c); + if (!includes('@everyone') && !includes('@here')) return; + + // It would be bad if we deleted a message that actually pinged @everyone or @here + if ( + this.member.permissionsIn(this.message.channelId).has(PermissionFlagsBits.MentionEveryone) || + this.message.mentions.everyone + ) + return; + + if ( + includes('steam') || + includes('www.youtube.com') || + includes('youtu.be') || + includes('nitro') || + includes('1 month') || + includes('3 months') || + includes('personalize your profile') || + includes('even more') || + includes('xbox and discord') || + includes('left over') || + includes('check this lol') || + includes('airdrop') + ) { + const color = this.logColor(Severity.PERM_MUTE); + this.punish({ severity: Severity.TEMP_MUTE, reason: 'everyone mention and scam phrase' } as BadWordDetails); + void this.guild!.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${Severity.TEMP_MUTE}] Mention Scam Deleted`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Sent From:** <#${this.message.channel.id}> [Jump to context](${this.message.url})` + ) + .addFields({ + name: 'Message Content', + value: `${await this.client.utils.codeblock(this.message.content, 1024)}` + }) + .setColor(color) + .setTimestamp() + ], + components: [this.buttons(this.user.id, 'everyone mention and scam phrase')] + }); + } + } + + protected async checkPerspectiveApi() { + return; + if (!this.client.config.isDevelopment) return; + + if (!this.message.content) return; + this.client.perspective.comments.analyze( + { + key: this.client.config.credentials.perspectiveApiKey, + resource: { + comment: { + text: this.message.content + }, + requestedAttributes: { + TOXICITY: {}, + SEVERE_TOXICITY: {}, + IDENTITY_ATTACK: {}, + INSULT: {}, + PROFANITY: {}, + THREAT: {}, + SEXUALLY_EXPLICIT: {}, + FLIRTATION: {} + } + } + }, + (err: any, response: any) => { + if (err) return console.log(err?.message); + + const normalize = (val: number, min: number, max: number) => (val - min) / (max - min); + + const color = (val: number) => { + if (val >= 0.5) { + const x = 194 - Math.round(normalize(val, 0.5, 1) * 194); + return chalk.rgb(194, x, 0)(val); + } else { + const x = Math.round(normalize(val, 0, 0.5) * 194); + return chalk.rgb(x, 194, 0)(val); + } + }; + + console.log(chalk.cyan(this.message.content)); + Object.entries(response.data.attributeScores) + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([key, value]: any[]) => console.log(chalk.white(key), color(value.summaryScore.value))); + } + ); + } + + /** + * Punishes the user based on the severity of the offense + * @param highestOffense The highest offense to punish the user for + * @returns The color of the embed that the log should, based on the severity of the offense + */ + protected punish(highestOffense: BadWordDetails) { + switch (highestOffense.severity) { + case Severity.DELETE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + this.punished = true; + break; + } + case Severity.WARN: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushWarn({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}` + }); + this.punished = true; + break; + } + case Severity.TEMP_MUTE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushMute({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}`, + duration: 900_000 // 15 minutes + }); + this.punished = true; + break; + } + case Severity.PERM_MUTE: { + void this.message.delete().catch((e) => deleteError.bind(this, e)); + void this.member.bushMute({ + moderator: this.guild!.members.me!, + reason: `[Automod] ${highestOffense.reason}`, + duration: 0 // permanent + }); + this.punished = true; + break; + } + default: { + throw new Error(`Invalid severity: ${highestOffense.severity}`); + } + } + + async function deleteError(this: MessageAutomod, e: Error | any) { + void this.guild?.sendLogChannel('error', { + embeds: [ + { + title: 'Automod Error', + description: `Unable to delete triggered message.`, + fields: [{ name: 'Error', value: await this.client.utils.codeblock(`${formatError(e)}`, 1024, 'js', true) }], + color: colors.error + } + ] + }); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[]) { + void this.client.console.info( + 'MessageAutomod', + `Severity <<${highestOffense.severity}>> action performed on <<${this.user.tag}>> (<<${this.user.id}>>) in <<#${ + (this.message.channel as GuildTextBasedChannel).name + }>> in <<${this.guild!.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild!.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automod Action Performed`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Sent From:** <#${this.message.channel.id}> [Jump to context](${this.message.url}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields({ + name: 'Message Content', + value: `${await this.client.utils.codeblock(this.message.content, 1024)}` + }) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: highestOffense.severity >= 2 ? [this.buttons(this.user.id, highestOffense.reason)] : undefined + }); + } +} diff --git a/lib/automod/PresenceAutomod.ts b/lib/automod/PresenceAutomod.ts new file mode 100644 index 0000000..70c66d6 --- /dev/null +++ b/lib/automod/PresenceAutomod.ts @@ -0,0 +1,85 @@ +import { stripIndent } from '#tags'; +import { EmbedBuilder, Presence } from 'discord.js'; +import { Automod, BadWordDetails } from './AutomodShared.js'; + +export class PresenceAutomod extends Automod { + /** + * @param presence The presence that the automod is checking + */ + public constructor(public readonly presence: Presence) { + super(presence.member!); + + if (presence.member!.id === presence.client.user?.id) return; + + void this.handle(); + } + + protected async handle(): Promise<void> { + if (this.presence.member!.user.bot) return; + + const badWordsRaw = Object.values(this.client.utils.getShared('badWords')).flat(); + const customAutomodPhrases = (await this.guild.getSetting('autoModPhases')) ?? []; + + const phrases = [...badWordsRaw, ...customAutomodPhrases].filter((p) => p.userInfo); + + const result: BadWordDetails[] = []; + + const strings = []; + + for (const activity of this.presence.activities) { + const str = `${activity.name}${activity.details ? `\n${activity.details}` : ''}${ + activity.buttons.length > 0 ? `\n${activity.buttons.join('\n')}` : '' + }`; + const check = this.checkWords(phrases, str); + if (check.length > 0) { + result.push(...check); + strings.push(str); + } + } + + if (result.length > 0) { + const highestOffense = result.sort((a, b) => b.severity - a.severity)[0]; + await this.logMessage(highestOffense, result, strings); + } + } + + /** + * Log an automod infraction to the guild's specified automod log channel + * @param highestOffense The highest severity word found in the message + * @param offenses The other offenses that were also matched in the message + */ + protected async logMessage(highestOffense: BadWordDetails, offenses: BadWordDetails[], strings: string[]) { + void this.client.console.info( + 'PresenceAutomod', + `Detected a severity <<${highestOffense.severity}>> automod phrase in <<${this.user.tag}>>'s (<<${this.user.id}>>) presence in <<${this.guild.name}>>` + ); + + const color = this.logColor(highestOffense.severity); + + await this.guild.sendLogChannel('automod', { + embeds: [ + new EmbedBuilder() + .setTitle(`[Severity ${highestOffense.severity}] Automoderated Status Detected`) + .setDescription( + stripIndent` + **User:** ${this.user} (${this.user.tag}) + **Blacklisted Words:** ${offenses.map((o) => `\`${o.match}\``).join(', ')}` + ) + .addFields( + ( + await Promise.all( + strings.map(async (s) => ({ + name: 'Status', + value: `${await this.client.utils.codeblock(s, 1024)}` + })) + ) + ).slice(0, 25) + ) + .setColor(color) + .setTimestamp() + .setAuthor({ name: this.user.tag, url: this.user.displayAvatarURL() }) + ], + components: [this.buttons(this.user.id, highestOffense.reason, false)] + }); + } +} |