diff options
Diffstat (limited to 'lib/common/Appeals.ts')
-rw-r--r-- | lib/common/Appeals.ts | 273 |
1 files changed, 273 insertions, 0 deletions
diff --git a/lib/common/Appeals.ts b/lib/common/Appeals.ts new file mode 100644 index 0000000..43c56fd --- /dev/null +++ b/lib/common/Appeals.ts @@ -0,0 +1,273 @@ +import { AppealStatus, ModLog } from '#lib/models/instance/ModLog.js'; +import { colors, emojis } from '#lib/utils/Constants.js'; +import { input } from '#lib/utils/Format.js'; +import { capitalize, ModalInput } from '#lib/utils/Utils.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + TextInputStyle, + type ButtonInteraction, + type ModalSubmitInteraction, + type Snowflake +} from 'discord.js'; +import assert from 'node:assert/strict'; +import { Action, punishments } from './Moderation.js'; + +type AppealBase = 'appeal_attempt' | 'appeal_submit' | 'appeal_accept' | 'appeal_deny'; + +type RawAppealInfo = [baseId: AppealBase, punishment: `${Action}`, guildId: Snowflake, userId: Snowflake, modlogId: string]; + +type AppealInfo = [baseId: AppealBase, punishment: Action, guildId: Snowflake, userId: Snowflake, modlogId: string]; + +export type AppealIdString = + `${RawAppealInfo[0]};${RawAppealInfo[1]};${RawAppealInfo[2]};${RawAppealInfo[3]};${RawAppealInfo[4]}`; + +function parseAppeal(customId: AppealIdString | string): AppealInfo { + const [baseId, _punishment, guildId, userId, modlogId] = customId.split(';') as RawAppealInfo; + + const punishment = Action[Action[_punishment] as keyof typeof Action]; + + return [baseId, punishment, guildId, userId, modlogId]; +} + +/** + * Handles when a user clicks the "Appeal Punishment" button on a punishment dm. + * @param interaction A button interaction with a custom id thar starts with "appeal_attempt;". + */ +export async function handleAppealAttempt(interaction: ButtonInteraction) { + const [baseId, punishment, guildId, userId, modlogId] = parseAppeal(interaction.customId); + + const { base, past, appealCustom } = punishments[punishment]; + const appealName = appealCustom ?? capitalize(base); + + const guild = interaction.client.guilds.resolve(guildId); + if (!guild) { + return await interaction.reply(`${emojis.error} I am no longer in that server.`); + } + + const modlog = await ModLog.findByPk(modlogId); + if (!modlog) { + return await interaction.reply(`:skull: I cannot find the modlog ${input(modlogId)}. Please report this to my developers.`); + } + + switch (modlog.appeal) { + case AppealStatus.Accepted: + return await interaction.reply( + `${emojis.error} Your punishment (${input(modlogId)}) has already been appealed and accepted.` + ); + case AppealStatus.Denied: + return await interaction.reply( + `${emojis.error} Your punishment (${input(modlogId)}) has already been appealed and denied.` + ); + case AppealStatus.Submitted: + return await interaction.reply( + `${emojis.error} Your punishment (${input( + modlogId + )}) has already been appealed, please be patient for a moderator to review your appeal.` + ); + default: { + const _exhaustiveCheck: AppealStatus.None = modlog.appeal; + } + } + + const baseInput = { + style: TextInputStyle.Paragraph, + required: true, + maxLength: 1024 + }; + + return await interaction.showModal({ + customId: `appeal_submit;${punishment};${guildId};${userId};${modlogId}`, + title: `${appealName} Appeal`, + components: [ + ModalInput({ + ...baseInput, + label: `Why were you ${past}?`, + placeholder: `Why do you think you received a ${base}?`, + customId: 'appeal_reason' + }), + ModalInput({ + ...baseInput, + label: 'Do you believe it was fair?', + placeholder: `Do you think that your ${base} is fair?`, + customId: 'appeal_fair' + }), + ModalInput({ + ...baseInput, + label: `Why should your ${base} be removed?`, + placeholder: `Why do you think your ${base} be removed?`, + customId: 'appeal_why' + }) + ] + }); +} + +/** + * Handles when a user submits the modal for appealing a punishment. + * @param interaction A modal interaction with a custom id that starts with "appeal_submit;". + */ +export async function handleAppealSubmit(interaction: ModalSubmitInteraction) { + const [baseId, punishment, guildId, userId, modlogId] = parseAppeal(interaction.customId); + + const { base, past, appealCustom } = punishments[punishment]; + const appealName = appealCustom ?? capitalize(base); + + const guild = interaction.client.guilds.resolve(guildId); + if (!guild) { + return await interaction.reply(`${emojis.error} I am no longer in that server.`); + } + + const modlog = await ModLog.findByPk(modlogId); + if (!modlog) { + return await interaction.reply(`:skull: I cannot find the modlog ${input(modlogId)}. Please report this to my developers.`); + } + + if (modlog.appeal !== AppealStatus.None) { + return await interaction.reply(`Invalid appeal status: ${modlog.appeal}`); + } + + modlog.appeal = AppealStatus.Submitted; + await modlog.save(); + + const appealChannel = await guild.getLogChannel('appeals'); + if (!appealChannel) { + return await interaction.reply(`${emojis.error} I could not find an appeals channel in this server.`); + } + + const user = await interaction.client.users.fetch(userId); + + const reason = interaction.fields.getTextInputValue('appeal_reason'); + const fair = interaction.fields.getTextInputValue('appeal_fair'); + const why = interaction.fields.getTextInputValue('appeal_why'); + + const embed = new EmbedBuilder() + .setTitle(`${appealName} Appeal`) + .setColor(colors.newBlurple) + .setTimestamp() + .setFooter({ text: `CaseID: ${modlogId}` }) + .setAuthor({ name: user.tag, iconURL: user.displayAvatarURL() }) + .addFields( + { name: `Why were you ${past}?`, value: reason }, + { name: 'Do you believe it was fair?', value: fair }, + { name: `Why should your ${base} be removed?`, value: why } + ); + return await appealChannel.send({ + content: `Appeal submitted by ${user.tag} (${user.id})`, + embeds: [embed], + components: [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + customId: `appeal_accept;${punishment};${guildId};${userId};${modlogId}`, + label: 'Accept Appeal', + style: ButtonStyle.Success + }), + new ButtonBuilder({ + customId: `appeal_deny;${punishment};${guildId};${userId};${modlogId}`, + label: 'Deny Appeal', + style: ButtonStyle.Danger + }) + ) + ] + }); +} + +/** + * Handles interactions when a moderator clicks the "Accept" or "Deny" button on a punishment appeal. + * @param interaction A button interaction with a custom id that starts with "appeal_accept;" or "appeal_deny;". + */ +export async function handleAppealDecision(interaction: ButtonInteraction) { + const [baseId, punishment, guildId, userId, modlogId] = parseAppeal(interaction.customId); + + const { base, past, appealCustom } = punishments[punishment]; + const appealName = (appealCustom ?? base).toLowerCase(); + + const modlog = await ModLog.findByPk(modlogId); + + if (!modlog) { + return await interaction.reply(`:skull: I cannot find the modlog ${input(modlogId)}. Please report this to my developers.`); + } + + if (modlog.appeal !== AppealStatus.Submitted) { + return await interaction.reply( + `:skull: Case ${input(modlogId)} has an invalid state of ${input(modlog.appeal)}. Please report this to my developers.` + ); + } + + if (baseId === 'appeal_deny') { + modlog.appeal = AppealStatus.Denied; + await modlog.save(); + + await interaction.client.users + .send(userId, `Your ${appealName} appeal has been denied in ${interaction.client.guilds.resolve(guildId)!}.`) + .catch(() => {}); + + return await interaction.update({ + content: `${emojis.cross} Appeal denied.`, + embeds: interaction.message.embeds, + components: [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + disabled: true, + style: ButtonStyle.Danger, + label: 'Appeal Denied', + custom_id: 'noop' + }) + ) + ] + }); + } else if (baseId === 'appeal_accept') { + modlog.appeal = AppealStatus.Accepted; + await modlog.save(); + + await interaction.client.users + .send(userId, `Your ${appealName} appeal has been accepted in ${interaction.client.guilds.resolve(guildId)!}.`) + .catch(() => {}); + + switch (punishment) { + case Action.Warn: + case Action.Unmute: + case Action.Kick: + case Action.Unban: + case Action.Untimeout: + case Action.Unblock: + case Action.RemovePunishRole: + assert.fail(`Cannot appeal ${appealName} (Action.${Action[punishment]})`); + return; + case Action.Mute: { + throw new Error('Not implemented'); + } + case Action.Ban: { + throw new Error('Not implemented'); + } + case Action.Timeout: { + throw new Error('Not implemented'); + } + case Action.Block: { + throw new Error('Not implemented'); + } + case Action.AddPunishRole: { + throw new Error('Not implemented'); + } + default: { + const _exhaustiveCheck: never = punishment; + } + } + + return await interaction.update({ + content: `${emojis.check} Appeal accepted.`, + embeds: interaction.message.embeds, + components: [ + new ActionRowBuilder<ButtonBuilder>().addComponents( + new ButtonBuilder({ + disabled: true, + style: ButtonStyle.Success, + label: 'Appeal Accepted', + custom_id: 'noop' + }) + ) + ] + }); + } +} |