aboutsummaryrefslogtreecommitdiff
path: root/lib/automod
diff options
context:
space:
mode:
Diffstat (limited to 'lib/automod')
-rw-r--r--lib/automod/AutomodShared.ts310
-rw-r--r--lib/automod/MemberAutomod.ts72
-rw-r--r--lib/automod/MessageAutomod.ts286
-rw-r--r--lib/automod/PresenceAutomod.ts85
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)]
+ });
+ }
+}