From 3d0f8d6284fbff51881ba704f73765100ffc5f47 Mon Sep 17 00:00:00 2001
From: IRONM00N <64110067+IRONM00N@users.noreply.github.com>
Date: Sun, 13 Feb 2022 20:39:09 -0500
Subject: started working on appeals

---
 src/lib/common/AutoMod.ts                          |   2 +
 src/lib/common/ButtonPaginator.ts                  |   7 +-
 src/lib/common/ConfirmationPrompt.ts               |   2 +
 src/lib/common/DeleteButton.ts                     |   1 +
 src/lib/common/util/Moderation.ts                  | 124 ++++++++++++++++++--
 .../extensions/discord-akairo/BushClientUtil.ts    |   2 +-
 src/lib/extensions/discord-akairo/BushCommand.ts   |   6 +-
 src/lib/extensions/discord.js/BushGuild.ts         |  29 ++---
 src/lib/extensions/discord.js/BushGuildMember.ts   | 127 +++++++++++++--------
 src/lib/models/instance/Guild.ts                   |   9 ++
 src/lib/utils/BushConstants.ts                     |   4 +-
 11 files changed, 232 insertions(+), 81 deletions(-)

(limited to 'src/lib')

diff --git a/src/lib/common/AutoMod.ts b/src/lib/common/AutoMod.ts
index 784085d..9024260 100644
--- a/src/lib/common/AutoMod.ts
+++ b/src/lib/common/AutoMod.ts
@@ -156,6 +156,7 @@ export class AutoMod {
 						? [
 								new ActionRow().addComponents(
 									new ButtonComponent()
+										// @ts-expect-error: outdated @discord.js/builders
 										.setStyle(ButtonStyle.Danger)
 										.setLabel('Ban User')
 										.setCustomId(`automod;ban;${this.message.author.id};everyone mention and scam phrase`)
@@ -277,6 +278,7 @@ export class AutoMod {
 					? [
 							new ActionRow().addComponents(
 								new ButtonComponent()
+									// @ts-expect-error: outdated @discord.js/builders
 									.setStyle(ButtonStyle.Danger)
 									.setLabel('Ban User')
 									.setCustomId(`automod;ban;${this.message.author.id};${highestOffence.reason}`)
diff --git a/src/lib/common/ButtonPaginator.ts b/src/lib/common/ButtonPaginator.ts
index 0399e74..09e059c 100644
--- a/src/lib/common/ButtonPaginator.ts
+++ b/src/lib/common/ButtonPaginator.ts
@@ -1,6 +1,6 @@
 import { DeleteButton, type BushMessage, type BushSlashMessage } from '#lib';
 import { CommandUtil } from 'discord-akairo';
-import { APIEmbed } from 'discord-api-types';
+import { APIEmbed } from 'discord-api-types/v9';
 import { ActionRow, ActionRowComponent, ButtonComponent, ButtonStyle, Embed, type MessageComponentInteraction } from 'discord.js';
 
 /**
@@ -173,26 +173,31 @@ export class ButtonPaginator {
 	protected getPaginationRow(disableAll = false): ActionRow<ActionRowComponent> {
 		return new ActionRow().addComponents(
 			new ButtonComponent()
+				// @ts-expect-error: outdated @discord.js/builders
 				.setStyle(ButtonStyle.Primary)
 				.setCustomId('paginate_beginning')
 				.setEmoji(PaginateEmojis.BEGINNING)
 				.setDisabled(disableAll || this.curPage === 0),
 			new ButtonComponent()
+				// @ts-expect-error: outdated @discord.js/builders
 				.setStyle(ButtonStyle.Primary)
 				.setCustomId('paginate_back')
 				.setEmoji(PaginateEmojis.BACK)
 				.setDisabled(disableAll || this.curPage === 0),
 			new ButtonComponent()
+				// @ts-expect-error: outdated @discord.js/builders
 				.setStyle(ButtonStyle.Primary)
 				.setCustomId('paginate_stop')
 				.setEmoji(PaginateEmojis.STOP)
 				.setDisabled(disableAll),
 			new ButtonComponent()
+				// @ts-expect-error: outdated @discord.js/builders
 				.setStyle(ButtonStyle.Primary)
 				.setCustomId('paginate_next')
 				.setEmoji(PaginateEmojis.FORWARD)
 				.setDisabled(disableAll || this.curPage === this.embeds.length - 1),
 			new ButtonComponent()
+				// @ts-expect-error: outdated @discord.js/builders
 				.setStyle(ButtonStyle.Primary)
 				.setCustomId('paginate_end')
 				.setEmoji(PaginateEmojis.END)
diff --git a/src/lib/common/ConfirmationPrompt.ts b/src/lib/common/ConfirmationPrompt.ts
index bd11c5c..1f027ef 100644
--- a/src/lib/common/ConfirmationPrompt.ts
+++ b/src/lib/common/ConfirmationPrompt.ts
@@ -31,11 +31,13 @@ export class ConfirmationPrompt {
 		this.messageOptions.components = [
 			new ActionRow().addComponents(
 				new ButtonComponent()
+					// @ts-expect-error: outdated @discord.js/builders
 					.setStyle(ButtonStyle.Primary)
 					.setCustomId('confirmationPrompt_confirm')
 					.setEmoji({ id: util.emojisRaw.successFull, name: 'successFull', animated: false })
 					.setLabel('Yes'),
 				new ButtonComponent()
+					// @ts-expect-error: outdated @discord.js/builders
 					.setStyle(ButtonStyle.Danger)
 					.setCustomId('confirmationPrompt_cancel')
 					.setEmoji({ id: util.emojisRaw.errorFull, name: 'errorFull', animated: false })
diff --git a/src/lib/common/DeleteButton.ts b/src/lib/common/DeleteButton.ts
index cf3b416..f2e0ff3 100644
--- a/src/lib/common/DeleteButton.ts
+++ b/src/lib/common/DeleteButton.ts
@@ -68,6 +68,7 @@ export class DeleteButton {
 		this.messageOptions.components = [
 			new ActionRow().addComponents(
 				new ButtonComponent()
+					// @ts-expect-error: outdated @discord.js/builders
 					.setStyle(ButtonStyle.Primary)
 					.setCustomId('paginate__stop')
 					.setEmoji(PaginateEmojis.STOP)
diff --git a/src/lib/common/util/Moderation.ts b/src/lib/common/util/Moderation.ts
index 0ba6fca..c2236ab 100644
--- a/src/lib/common/util/Moderation.ts
+++ b/src/lib/common/util/Moderation.ts
@@ -10,7 +10,33 @@ import {
 	type BushUserResolvable,
 	type ModLogType
 } from '#lib';
-import { Embed, PermissionFlagsBits, type Snowflake } from 'discord.js';
+import assert from 'assert';
+import { ActionRow, ButtonComponent, ButtonStyle, ComponentType, Embed, PermissionFlagsBits, type Snowflake } from 'discord.js';
+
+enum punishMap {
+	'warned' = 'warn',
+	'muted' = 'mute',
+	'unmuted' = 'unmute',
+	'kicked' = 'kick',
+	'banned' = 'ban',
+	'unbanned' = 'unban',
+	'timedout' = 'timeout',
+	'untimedout' = 'untimeout',
+	'blocked' = 'block',
+	'unblocked' = 'unblock'
+}
+enum reversedPunishMap {
+	'warn' = 'warned',
+	'mute' = 'muted',
+	'unmute' = 'unmuted',
+	'kick' = 'kicked',
+	'ban' = 'banned',
+	'unban' = 'unbanned',
+	'timeout' = 'timedout',
+	'untimeout' = 'untimedout',
+	'block' = 'blocked',
+	'unblock' = 'unblocked'
+}
 
 /**
  * A utility class with moderation-related methods.
@@ -204,6 +230,19 @@ export class Moderation {
 		return typeMap[type];
 	}
 
+	public static punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent {
+		return punishMap[punishment];
+	}
+
+	public static punishmentToPastTense(punishment: PunishmentTypePresent): PunishmentTypeDM {
+		return reversedPunishMap[punishment];
+	}
+
+	/**
+	 * Notifies the specified user of their punishment.
+	 * @param options Options for notifying the user.
+	 * @returns Whether or not the dm was successfully sent.
+	 */
 	public static async punishDM(options: PunishDMOptions): Promise<boolean> {
 		const ending = await options.guild.getSetting('punishmentEnding');
 		const dmEmbed =
@@ -211,16 +250,45 @@ export class Moderation {
 				? new Embed().setDescription(ending).setColor(util.colors.newBlurple)
 				: undefined;
 
+		const appealsEnabled = !!(
+			(await options.guild.hasFeature('punishmentAppeals')) && (await options.guild.getLogChannel('appeals'))
+		);
+
+		let content = `You have been ${options.punishment} `;
+		if (options.punishment.includes('blocked')) {
+			assert(options.channel);
+			content += `from <#${options.channel}> `;
+		}
+		content += `in ${util.format.input(options.guild.name)} `;
+		if (options.duration !== null && options.duration !== undefined)
+			content += options.duration ? `for ${util.humanizeDuration(options.duration)} ` : 'permanently ';
+		const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided';
+		content += `for ${util.format.input(reason)}.`;
+
+		let components;
+		if (appealsEnabled && options.modlog)
+			components = [
+				new ActionRow({
+					type: ComponentType.ActionRow,
+					components: [
+						// @ts-expect-error: outdated @discord.js/builders
+						new ButtonComponent({
+							custom_id: `appeal;${this.punishmentToPresentTense(options.punishment)};${
+								options.guild.id
+							};${client.users.resolveId(options.user)};${options.modlog}`,
+							style: ButtonStyle.Primary,
+							type: ComponentType.Button,
+							label: 'Appeal'
+						})
+					]
+				})
+			];
+
 		const dmSuccess = await client.users
 			.send(options.user, {
-				content: `You have been ${options.punishment} in **${options.guild.name}** ${
-					options.duration !== null && options.duration !== undefined
-						? options.duration
-							? `for ${util.humanizeDuration(options.duration)} `
-							: 'permanently '
-						: ''
-				}for **${options.reason?.trim() ? options.reason?.trim() : 'No reason provided'}**.`,
-				embeds: dmEmbed ? [dmEmbed] : undefined
+				content,
+				embeds: dmEmbed ? [dmEmbed] : undefined,
+				components
 			})
 			.catch(() => false);
 		return !!dmSuccess;
@@ -341,6 +409,11 @@ export interface RemovePunishmentEntryOptions {
  * Options for sending a user a punishment dm.
  */
 export interface PunishDMOptions {
+	/**
+	 * The modlog case id so the user can make an appeal.
+	 */
+	modlog?: string;
+
 	/**
 	 * The guild that the punishment is taking place in.
 	 */
@@ -354,7 +427,7 @@ export interface PunishDMOptions {
 	/**
 	 * The punishment that the user has received.
 	 */
-	punishment: string;
+	punishment: PunishmentTypeDM;
 
 	/**
 	 * The reason the user's punishment.
@@ -371,4 +444,35 @@ export interface PunishDMOptions {
 	 * @default true
 	 */
 	sendFooter: boolean;
+
+	/**
+	 * The channel that the user was (un)blocked from.
+	 */
+	channel?: Snowflake;
 }
+
+export type PunishmentTypeDM =
+	| 'warned'
+	| 'muted'
+	| 'unmuted'
+	| 'kicked'
+	| 'banned'
+	| 'unbanned'
+	| 'timedout'
+	| 'untimedout'
+	| 'blocked'
+	| 'unblocked';
+
+export type PunishmentTypePresent =
+	| 'warn'
+	| 'mute'
+	| 'unmute'
+	| 'kick'
+	| 'ban'
+	| 'unban'
+	| 'timeout'
+	| 'untimeout'
+	| 'block'
+	| 'unblock';
+
+export type AppealButtonId = `appeal;${PunishmentTypePresent};${Snowflake};${Snowflake};${string}`;
diff --git a/src/lib/extensions/discord-akairo/BushClientUtil.ts b/src/lib/extensions/discord-akairo/BushClientUtil.ts
index 41d16f7..bf4dfaf 100644
--- a/src/lib/extensions/discord-akairo/BushClientUtil.ts
+++ b/src/lib/extensions/discord-akairo/BushClientUtil.ts
@@ -21,7 +21,7 @@ import assert from 'assert';
 import { exec } from 'child_process';
 import deepLock from 'deep-lock';
 import { ClientUtil, Util as AkairoUtil } from 'discord-akairo';
-import { APIMessage } from 'discord-api-types';
+import type { APIMessage } from 'discord-api-types/v9';
 import {
 	Constants as DiscordConstants,
 	GuildMember,
diff --git a/src/lib/extensions/discord-akairo/BushCommand.ts b/src/lib/extensions/discord-akairo/BushCommand.ts
index 650b538..ff3748e 100644
--- a/src/lib/extensions/discord-akairo/BushCommand.ts
+++ b/src/lib/extensions/discord-akairo/BushCommand.ts
@@ -44,7 +44,7 @@ import {
 	type ContextMenuCommand,
 	type MissingPermissionSupplier,
 	type SlashOption,
-	type SlashResolveTypes
+	type SlashResolveType
 } from 'discord-akairo';
 import {
 	type ApplicationCommandOptionChoice,
@@ -147,7 +147,7 @@ interface BaseBushArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt
 	 *
 	 * ex. get the resolved member object when the type is `USER`
 	 */
-	slashResolve?: SlashResolveTypes;
+	slashResolve?: SlashResolveType;
 
 	/**
 	 * The choices of the option for the user to pick from
@@ -340,7 +340,7 @@ export interface ArgsInfo {
 	description: string;
 	optional?: boolean;
 	slashType: AkairoApplicationCommandOptionData['type'] | false;
-	slashResolve?: SlashResolveTypes;
+	slashResolve?: SlashResolveType;
 	only?: 'slash' | 'text';
 	type: string;
 }
diff --git a/src/lib/extensions/discord.js/BushGuild.ts b/src/lib/extensions/discord.js/BushGuild.ts
index 93875b8..80799fd 100644
--- a/src/lib/extensions/discord.js/BushGuild.ts
+++ b/src/lib/extensions/discord.js/BushGuild.ts
@@ -173,8 +173,22 @@ export class BushGuild extends Guild {
 		if ((await this.bans.fetch()).has(user.id)) return banResponse.ALREADY_BANNED;
 
 		const ret = await (async () => {
+			// add modlog entry
+			const { log: modlog } = await Moderation.createModLogEntry({
+				type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN,
+				user: user,
+				moderator: moderator.id,
+				reason: options.reason,
+				duration: options.duration,
+				guild: this,
+				evidence: options.evidence
+			});
+			if (!modlog) return banResponse.MODLOG_ERROR;
+			caseID = modlog.id;
+
 			// dm user
 			dmSuccessEvent = await Moderation.punishDM({
+				modlog: modlog.id,
 				guild: this,
 				user: user,
 				punishment: 'banned',
@@ -187,24 +201,11 @@ export class BushGuild extends Guild {
 			const banSuccess = await this.bans
 				.create(user?.id ?? options.user, {
 					reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`,
-					days: options.deleteDays
+					deleteMessageDays: options.deleteDays
 				})
 				.catch(() => false);
 			if (!banSuccess) return banResponse.ACTION_ERROR;
 
-			// add modlog entry
-			const { log: modlog } = await Moderation.createModLogEntry({
-				type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN,
-				user: user,
-				moderator: moderator.id,
-				reason: options.reason,
-				duration: options.duration,
-				guild: this,
-				evidence: options.evidence
-			});
-			if (!modlog) return banResponse.MODLOG_ERROR;
-			caseID = modlog.id;
-
 			// add punishment entry so they can be unbanned later
 			const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
 				type: 'ban',
diff --git a/src/lib/extensions/discord.js/BushGuildMember.ts b/src/lib/extensions/discord.js/BushGuildMember.ts
index 84fdf13..5d7144b 100644
--- a/src/lib/extensions/discord.js/BushGuildMember.ts
+++ b/src/lib/extensions/discord.js/BushGuildMember.ts
@@ -3,6 +3,8 @@ import {
 	BushClientEvents,
 	Moderation,
 	ModLogType,
+	PunishmentTypeDM,
+	Time,
 	type BushClient,
 	type BushGuild,
 	type BushGuildTextBasedChannel,
@@ -29,14 +31,29 @@ export class BushGuildMember extends GuildMember {
 
 	/**
 	 * Send a punishment dm to the user.
+	 * @param modlog The modlog case id so the user can make an appeal.
 	 * @param punishment The punishment that the user has received.
 	 * @param reason The reason for the user's punishment.
 	 * @param duration The duration of the punishment.
 	 * @param sendFooter Whether or not to send the guild's punishment footer with the dm.
 	 * @returns Whether or not the dm was sent successfully.
 	 */
-	public async bushPunishDM(punishment: string, reason?: string | null, duration?: number, sendFooter = true): Promise<boolean> {
-		return Moderation.punishDM({ guild: this.guild, user: this, punishment, reason: reason ?? undefined, duration, sendFooter });
+	public async bushPunishDM(
+		punishment: PunishmentTypeDM,
+		reason?: string | null,
+		duration?: number,
+		modlog?: string,
+		sendFooter = true
+	): Promise<boolean> {
+		return Moderation.punishDM({
+			modlog,
+			guild: this.guild,
+			user: this,
+			punishment,
+			reason: reason ?? undefined,
+			duration,
+			sendFooter
+		});
 	}
 
 	/**
@@ -304,7 +321,7 @@ export class BushGuildMember extends GuildMember {
 
 			if (!options.silent) {
 				// dm user
-				const dmSuccess = await this.bushPunishDM('muted', options.reason, options.duration ?? 0);
+				const dmSuccess = await this.bushPunishDM('muted', options.reason, options.duration ?? 0, modlog.id);
 				dmSuccessEvent = dmSuccess;
 				if (!dmSuccess) return muteResponse.DM_ERROR;
 			}
@@ -386,7 +403,7 @@ export class BushGuildMember extends GuildMember {
 
 			if (!options.silent) {
 				// dm user
-				const dmSuccess = await this.bushPunishDM('unmuted', options.reason, undefined, false);
+				const dmSuccess = await this.bushPunishDM('unmuted', options.reason, undefined, '', false);
 				dmSuccessEvent = dmSuccess;
 				if (!dmSuccess) return unmuteResponse.DM_ERROR;
 			}
@@ -429,14 +446,6 @@ export class BushGuildMember extends GuildMember {
 		const moderator = await util.resolveNonCachedUser(options.moderator ?? this.guild.me);
 		if (!moderator) return kickResponse.CANNOT_RESOLVE_USER;
 		const ret = await (async () => {
-			// dm user
-			const dmSuccess = options.silent ? null : await this.bushPunishDM('kicked', options.reason);
-			dmSuccessEvent = dmSuccess ?? undefined;
-
-			// kick
-			const kickSuccess = await this.kick(`${moderator?.tag} | ${options.reason ?? 'No reason provided.'}`).catch(() => false);
-			if (!kickSuccess) return kickResponse.ACTION_ERROR;
-
 			// add modlog entry
 			const { log: modlog } = await Moderation.createModLogEntry({
 				type: ModLogType.KICK,
@@ -449,6 +458,15 @@ export class BushGuildMember extends GuildMember {
 			});
 			if (!modlog) return kickResponse.MODLOG_ERROR;
 			caseID = modlog.id;
+
+			// dm user
+			const dmSuccess = options.silent ? null : await this.bushPunishDM('kicked', options.reason, undefined, modlog.id);
+			dmSuccessEvent = dmSuccess ?? undefined;
+
+			// kick
+			const kickSuccess = await this.kick(`${moderator?.tag} | ${options.reason ?? 'No reason provided.'}`).catch(() => false);
+			if (!kickSuccess) return kickResponse.ACTION_ERROR;
+
 			if (dmSuccess === false) return kickResponse.DM_ERROR;
 			return kickResponse.SUCCESS;
 		})();
@@ -489,17 +507,6 @@ export class BushGuildMember extends GuildMember {
 		});
 
 		const ret = await (async () => {
-			// dm user
-			const dmSuccess = options.silent ? null : await this.bushPunishDM('banned', options.reason, options.duration ?? 0);
-			dmSuccessEvent = dmSuccess ?? undefined;
-
-			// ban
-			const banSuccess = await this.ban({
-				reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`,
-				days: options.deleteDays
-			}).catch(() => false);
-			if (!banSuccess) return banResponse.ACTION_ERROR;
-
 			// add modlog entry
 			const { log: modlog } = await Moderation.createModLogEntry({
 				type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN,
@@ -514,6 +521,19 @@ export class BushGuildMember extends GuildMember {
 			if (!modlog) return banResponse.MODLOG_ERROR;
 			caseID = modlog.id;
 
+			// dm user
+			const dmSuccess = options.silent
+				? null
+				: await this.bushPunishDM('banned', options.reason, options.duration ?? 0, modlog.id);
+			dmSuccessEvent = dmSuccess ?? undefined;
+
+			// ban
+			const banSuccess = await this.ban({
+				reason: `${moderator.tag} | ${options.reason ?? 'No reason provided.'}`,
+				deleteMessageDays: options.deleteDays
+			}).catch(() => false);
+			if (!banSuccess) return banResponse.ACTION_ERROR;
+
 			// add punishment entry so they can be unbanned later
 			const punishmentEntrySuccess = await Moderation.createPunishmentEntry({
 				type: 'ban',
@@ -595,20 +615,21 @@ export class BushGuildMember extends GuildMember {
 			});
 			if (!punishmentEntrySuccess) return blockResponse.PUNISHMENT_ENTRY_ADD_ERROR;
 
-			if (!options.silent) {
-				// dm user
-				const dmSuccess = await this.send({
-					content: `You have been blocked from <#${channel.id}> in **${this.guild.name}** ${
-						options.duration !== null && options.duration !== undefined
-							? options.duration
-								? `for ${util.humanizeDuration(options.duration)} `
-								: 'permanently '
-							: ''
-					}for **${options.reason?.trim() ? options.reason?.trim() : 'No reason provided'}**.`
-				}).catch(() => false);
-				dmSuccessEvent = !!dmSuccess;
-				if (!dmSuccess) return blockResponse.DM_ERROR;
-			}
+			// dm user
+			const dmSuccess = options.silent
+				? null
+				: await Moderation.punishDM({
+						punishment: 'blocked',
+						reason: options.reason ?? undefined,
+						duration: options.duration ?? 0,
+						modlog: modlog.id,
+						guild: this.guild,
+						user: this,
+						sendFooter: true,
+						channel: channel.id
+				  });
+			dmSuccessEvent = !!dmSuccess;
+			if (!dmSuccess) return blockResponse.DM_ERROR;
 
 			return blockResponse.SUCCESS;
 		})();
@@ -683,16 +704,22 @@ export class BushGuildMember extends GuildMember {
 			});
 			if (!punishmentEntrySuccess) return unblockResponse.ACTION_ERROR;
 
-			if (!options.silent) {
-				// dm user
-				const dmSuccess = await this.send({
-					content: `You have been unblocked from <#${channel.id}> in **${this.guild.name}** for **${
-						options.reason?.trim() ? options.reason?.trim() : 'No reason provided'
-					}**.`
-				}).catch(() => false);
-				dmSuccessEvent = !!dmSuccess;
-				if (!dmSuccess) return unblockResponse.DM_ERROR;
-			}
+			// dm user
+			const dmSuccess = options.silent
+				? null
+				: await Moderation.punishDM({
+						punishment: 'unblocked',
+						reason: options.reason ?? undefined,
+						guild: this.guild,
+						user: this,
+						sendFooter: false,
+						channel: channel.id
+				  });
+			dmSuccessEvent = !!dmSuccess;
+			if (!dmSuccess) return blockResponse.DM_ERROR;
+
+			dmSuccessEvent = !!dmSuccess;
+			if (!dmSuccess) return unblockResponse.DM_ERROR;
 
 			return unblockResponse.SUCCESS;
 		})();
@@ -723,7 +750,7 @@ export class BushGuildMember extends GuildMember {
 		// checks
 		if (!this.guild.me!.permissions.has(PermissionFlagsBits.ModerateMembers)) return timeoutResponse.MISSING_PERMISSIONS;
 
-		const twentyEightDays = client.consts.timeUnits.days.value * 28;
+		const twentyEightDays = Time.Day * 28;
 		if (options.duration > twentyEightDays) return timeoutResponse.INVALID_DURATION;
 
 		let caseID: string | undefined = undefined;
@@ -756,7 +783,7 @@ export class BushGuildMember extends GuildMember {
 
 			if (!options.silent) {
 				// dm user
-				const dmSuccess = await this.bushPunishDM('timed out', options.reason, options.duration);
+				const dmSuccess = await this.bushPunishDM('timedout', options.reason, options.duration, modlog.id);
 				dmSuccessEvent = dmSuccess;
 				if (!dmSuccess) return timeoutResponse.DM_ERROR;
 			}
@@ -815,7 +842,7 @@ export class BushGuildMember extends GuildMember {
 
 			if (!options.silent) {
 				// dm user
-				const dmSuccess = await this.bushPunishDM('untimedout', options.reason);
+				const dmSuccess = await this.bushPunishDM('untimedout', options.reason, undefined, '', false);
 				dmSuccessEvent = dmSuccess;
 				if (!dmSuccess) return removeTimeoutResponse.DM_ERROR;
 			}
diff --git a/src/lib/models/instance/Guild.ts b/src/lib/models/instance/Guild.ts
index b41eb9e..b81562c 100644
--- a/src/lib/models/instance/Guild.ts
+++ b/src/lib/models/instance/Guild.ts
@@ -385,6 +385,11 @@ export const guildFeaturesObj = asGuildFeature({
 		name: 'Log Manual Punishments',
 		description: "Adds manual punishment to the user's modlogs and the logging channels.",
 		default: true
+	},
+	punishmentAppeals: {
+		name: 'Punishment Appeals',
+		description: 'Allow users to appeal their punishments and send the appeal to the configured channel.',
+		default: false
 	}
 });
 
@@ -404,6 +409,10 @@ export const guildLogsObj = {
 	error: {
 		description: 'Logs errors that occur with the bot.',
 		configurable: true
+	},
+	appeals: {
+		description: 'Where punishment appeals are sent.',
+		configurable: true
 	}
 };
 
diff --git a/src/lib/utils/BushConstants.ts b/src/lib/utils/BushConstants.ts
index 4327fec..93de100 100644
--- a/src/lib/utils/BushConstants.ts
+++ b/src/lib/utils/BushConstants.ts
@@ -317,7 +317,6 @@ export class BushConstants {
 		},
 
 		userFlags: {
-			None: '',
 			Staff: '<:discordEmployee:848742947826434079>',
 			Partner: '<:partneredServerOwner:848743051593777152>',
 			Hypesquad: '<:hypeSquadEvents:848743108283072553>',
@@ -331,7 +330,8 @@ export class BushConstants {
 			VerifiedBot: '<:verifiedbot_rebrand1:938928232667947028><:verifiedbot_rebrand2:938928355707879475>',
 			VerifiedDeveloper: '<:earlyVerifiedBotDeveloper:848741079875846174>',
 			CertifiedModerator: '<:discordCertifiedModerator:877224285901582366>',
-			BotHTTPInteractions: 'BotHTTPInteractions'
+			BotHTTPInteractions: 'BotHTTPInteractions',
+			Spammer: 'Spammer'
 		},
 
 		status: {
-- 
cgit