diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-10-03 22:57:40 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-10-03 22:57:40 -0400 |
commit | 612ed820a0600ec11ed642005377cd7f5a8a8b77 (patch) | |
tree | 6bca4e7268fd0063ff53cf64fa44df62a23dba50 /lib/common | |
parent | ed98ff7e2679f362f2657e77a6cf8dd3ce9b3d43 (diff) | |
download | tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.tar.gz tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.tar.bz2 tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.zip |
wip
Diffstat (limited to 'lib/common')
-rw-r--r-- | lib/common/Appeals.ts | 273 | ||||
-rw-r--r-- | lib/common/ButtonPaginator.ts | 6 | ||||
-rw-r--r-- | lib/common/ConfirmationPrompt.ts | 12 | ||||
-rw-r--r-- | lib/common/DeleteButton.ts | 8 | ||||
-rw-r--r-- | lib/common/HighlightManager.ts | 3 | ||||
-rw-r--r-- | lib/common/Moderation.ts | 668 | ||||
-rw-r--r-- | lib/common/tags.ts | 132 |
7 files changed, 793 insertions, 309 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' + }) + ) + ] + }); + } +} diff --git a/lib/common/ButtonPaginator.ts b/lib/common/ButtonPaginator.ts index 02c78ea..c8c9229 100644 --- a/lib/common/ButtonPaginator.ts +++ b/lib/common/ButtonPaginator.ts @@ -1,5 +1,5 @@ import { DeleteButton, type CommandMessage, type SlashMessage } from '#lib'; -import { CommandUtil } from 'discord-akairo'; +import { CommandUtil } from '@notenoughupdates/discord-akairo'; import { ActionRowBuilder, ButtonBuilder, @@ -34,7 +34,7 @@ export class ButtonPaginator { protected constructor( protected message: CommandMessage | SlashMessage, protected embeds: EmbedBuilder[] | APIEmbed[], - protected text: string | null, + protected text: string, protected deleteOnExit: boolean, startOn: number ) { @@ -199,7 +199,7 @@ export class ButtonPaginator { public static async send( message: CommandMessage | SlashMessage, embeds: EmbedBuilder[] | APIEmbed[], - text: string | null = null, + text: string = '', deleteOnExit = true, startOn = 1 ) { diff --git a/lib/common/ConfirmationPrompt.ts b/lib/common/ConfirmationPrompt.ts index b87d9ef..631b8de 100644 --- a/lib/common/ConfirmationPrompt.ts +++ b/lib/common/ConfirmationPrompt.ts @@ -1,5 +1,11 @@ import { type CommandMessage, type SlashMessage } from '#lib'; -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type MessageComponentInteraction, type MessageOptions } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + type MessageComponentInteraction, + type MessageCreateOptions +} from 'discord.js'; /** * Sends a message with buttons for the user to confirm or cancel the action. @@ -9,7 +15,7 @@ export class ConfirmationPrompt { * @param message The message that triggered the command * @param messageOptions Options for sending the message */ - protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageCreateOptions) {} /** * Sends a message with buttons for the user to confirm or cancel the action. @@ -58,7 +64,7 @@ export class ConfirmationPrompt { * @param message The message that triggered the command * @param sendOptions Options for sending the message */ - public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageOptions): Promise<boolean> { + public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageCreateOptions): Promise<boolean> { return new ConfirmationPrompt(message, sendOptions).send(); } } diff --git a/lib/common/DeleteButton.ts b/lib/common/DeleteButton.ts index 340d07f..2d053cd 100644 --- a/lib/common/DeleteButton.ts +++ b/lib/common/DeleteButton.ts @@ -1,5 +1,5 @@ import { PaginateEmojis, type CommandMessage, type SlashMessage } from '#lib'; -import { CommandUtil } from 'discord-akairo'; +import { CommandUtil } from '@notenoughupdates/discord-akairo'; import { ActionRowBuilder, ButtonBuilder, @@ -7,7 +7,7 @@ import { MessageComponentInteraction, MessageEditOptions, MessagePayload, - type MessageOptions + type MessageCreateOptions } from 'discord.js'; /** @@ -18,7 +18,7 @@ export class DeleteButton { * @param message The message to respond to * @param messageOptions The send message options */ - protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageOptions) {} + protected constructor(protected message: CommandMessage | SlashMessage, protected messageOptions: MessageCreateOptions) {} /** * Sends a message with a button for the user to delete it. @@ -72,7 +72,7 @@ export class DeleteButton { * @param message The message to respond to * @param options The send message options */ - public static async send(message: CommandMessage | SlashMessage, options: Omit<MessageOptions, 'components'>) { + public static async send(message: CommandMessage | SlashMessage, options: Omit<MessageCreateOptions, 'components'>) { return new DeleteButton(message, options).send(); } } diff --git a/lib/common/HighlightManager.ts b/lib/common/HighlightManager.ts index ca71a83..3a76a5b 100644 --- a/lib/common/HighlightManager.ts +++ b/lib/common/HighlightManager.ts @@ -435,6 +435,9 @@ export class HighlightManager { } private generateDmEmbed(message: Message, hl: HighlightWord) { + // janky MessageManager typings + assert(message.inGuild()); + const recentMessages = message.channel.messages.cache .filter((m) => m.createdTimestamp <= message.createdTimestamp && m.id !== message.id) .filter((m) => m.cleanContent?.trim().length > 0) diff --git a/lib/common/Moderation.ts b/lib/common/Moderation.ts index 60e32c0..7697b2f 100644 --- a/lib/common/Moderation.ts +++ b/lib/common/Moderation.ts @@ -1,17 +1,7 @@ -import { - ActivePunishment, - ActivePunishmentType, - baseMuteResponse, - colors, - emojis, - format, - Guild as GuildDB, - humanizeDuration, - ModLog, - permissionsResponse, - type ModLogType, - type ValueOf -} from '#lib'; +import { baseMuteResponse, permissionsResponse } from '#lib/extensions/discord.js/ExtendedGuildMember.js'; +import { ActivePunishment, ActivePunishmentType, Guild as GuildDB, ModLog, type ModLogType } from '#lib/models/index.js'; +import { colors, emojis } from '#lib/utils/Constants.js'; +import { format, humanizeDuration, ValueOf } from '#lib/utils/Utils.js'; import assert from 'assert/strict'; import { ActionRowBuilder, @@ -28,29 +18,178 @@ import { type UserResolvable } 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' +export enum Action { + Warn, + Mute, + Unmute, + Kick, + Ban, + Unban, + Timeout, + Untimeout, + Block, + Unblock, + AddPunishRole, + RemovePunishRole +} + +interface ActionInfo { + /** + * The base verb of the action + */ + base: string; + + /** + * The past tense form of the action + */ + past: string; + + /** + * Whether or not a user can appeal this action + */ + appealable: boolean; + + /** + * Whether a moderator can perform this action on themself. + */ + selfInflictable: boolean; + + /** + * Whether the action requires the target to be in the guild. + */ + membershipRequired: boolean; + + /** + * Custom appeal title, otherwise {@link ActionInfo.base} is used. + */ + appealCustom?: string; } -enum reversedPunishMap { - 'warn' = 'warned', - 'mute' = 'muted', - 'unmute' = 'unmuted', - 'kick' = 'kicked', - 'ban' = 'banned', - 'unban' = 'unbanned', - 'timeout' = 'timedout', - 'untimeout' = 'untimedout', - 'block' = 'blocked', - 'unblock' = 'unblocked' + +export const punishments: Record<Action, ActionInfo> = { + [Action.Warn]: { + base: 'warn', + past: 'warned', + appealable: false, + selfInflictable: false, + membershipRequired: true + }, + [Action.Mute]: { + base: 'mute', + past: 'muted', + appealable: true, + selfInflictable: false, + membershipRequired: true + }, + [Action.Unmute]: { + base: 'unmute', + past: 'unmuted', + appealable: false, + selfInflictable: true, + membershipRequired: true + }, + [Action.Kick]: { + base: 'kick', + past: 'kicked', + appealable: false, + selfInflictable: false, + membershipRequired: true + }, + [Action.Ban]: { + base: 'ban', + past: 'banned', + appealable: true, + selfInflictable: false, + membershipRequired: false + }, + [Action.Unban]: { + base: 'unban', + past: 'unbanned', + appealable: false, + selfInflictable: true, + membershipRequired: false + }, + [Action.Timeout]: { + base: 'timeout', + past: 'timed out', + appealable: true, + selfInflictable: false, + membershipRequired: true + }, + [Action.Untimeout]: { + base: 'untimeout', + past: 'untimed out', + appealable: false, + selfInflictable: true, + membershipRequired: true + }, + [Action.Block]: { + base: 'block', + past: 'blocked', + appealable: true, + selfInflictable: false, + membershipRequired: true + }, + [Action.Unblock]: { + base: 'unblock', + past: 'unblocked', + appealable: false, + selfInflictable: true, + membershipRequired: true + }, + [Action.AddPunishRole]: { + base: 'add a punishment role to', + past: 'added punishment role', + appealable: true, + appealCustom: 'Punishment Role', + selfInflictable: false, + membershipRequired: true + }, + [Action.RemovePunishRole]: { + base: 'remove a punishment role from', + past: 'removed punishment role', + appealable: false, + selfInflictable: true, + membershipRequired: true + } +}; + +interface BaseOptions { + /** + * The client. + */ + client: Client; +} + +interface BaseCreateModLogEntryOptions extends BaseOptions { + /** + * The type of modlog entry. + */ + type: ModLogType; + + /** + * The reason for the punishment. + */ + reason: string | undefined | null; + + /** + * The duration of the punishment. + */ + duration?: number; + + /** + * Whether the punishment is a pseudo punishment. + */ + pseudo?: boolean; + + /** + * The evidence for the punishment. + */ + evidence?: string; + + /** + * Makes the modlog entry hidden. + */ + hidden?: boolean; } /** @@ -65,57 +204,52 @@ enum reversedPunishMap { export async function permissionCheck( moderator: GuildMember, victim: GuildMember, - type: - | 'mute' - | 'unmute' - | 'warn' - | 'kick' - | 'ban' - | 'unban' - | 'add a punishment role to' - | 'remove a punishment role from' - | 'block' - | 'unblock' - | 'timeout' - | 'untimeout', + type: Action, checkModerator = true, force = false ): Promise<true | string> { if (force) return true; + const action = punishments[type]; + // If the victim is not in the guild anymore it will be undefined - if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; + if (!victim?.guild && action.membershipRequired) return true; - if (moderator.guild.id !== victim.guild.id) { - throw new Error('moderator and victim not in same guild'); - } + assert(moderator.guild.id === victim.guild.id, 'moderator and victim should be from the same guild'); const isOwner = moderator.guild.ownerId === moderator.id; - if (moderator.id === victim.id && !type.startsWith('un')) { - return `${emojis.error} You cannot ${type} yourself.`; + + const selfInflicted = moderator.id === victim.id; + + if (selfInflicted && !action.selfInflictable) { + return `${emojis.error} You cannot ${action.base} yourself.`; } if ( moderator.roles.highest.position <= victim.roles.highest.position && !isOwner && - !(type.startsWith('un') && moderator.id === victim.id) + !(action.selfInflictable && selfInflicted) ) { - return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; + return `${emojis.error} You cannot ${action.base} ${format.input( + victim.user.tag + )} because they have higher or equal role hierarchy as you do.`; } if ( victim.roles.highest.position >= victim.guild.members.me!.roles.highest.position && - !(type.startsWith('un') && moderator.id === victim.id) + !(action.selfInflictable && selfInflicted) ) { - return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as I do.`; + return `${emojis.error} You cannot ${action.base} ${format.input( + victim.user.tag + )} because they have higher or equal role hierarchy as I do.`; } if ( checkModerator && victim.permissions.has(PermissionFlagsBits.ManageMessages) && - !(type.startsWith('un') && moderator.id === victim.id) + !(action.selfInflictable && selfInflicted) ) { if (await moderator.guild.hasFeature('modsCanPunishMods')) { return true; } else { - return `${emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; + return `${emojis.error} You cannot ${action.base} ${format.input(victim.user.tag)} because they are a moderator.`; } } return true; @@ -129,18 +263,49 @@ export async function permissionCheck( export async function checkMutePermissions( guild: Guild ): Promise<ValueOf<typeof baseMuteResponse> | ValueOf<typeof permissionsResponse> | true> { - if (!guild.members.me!.permissions.has('ManageRoles')) return permissionsResponse.MISSING_PERMISSIONS; + if (!guild.members.me!.permissions.has('ManageRoles')) { + return permissionsResponse.MISSING_PERMISSIONS; + } + const muteRoleID = await guild.getSetting('muteRole'); if (!muteRoleID) return baseMuteResponse.NO_MUTE_ROLE; + const muteRole = guild.roles.cache.get(muteRoleID); if (!muteRole) return baseMuteResponse.MUTE_ROLE_INVALID; - if (muteRole.position >= guild.members.me!.roles.highest.position || muteRole.managed) + + if (muteRole.position >= guild.members.me!.roles.highest.position || muteRole.managed) { return baseMuteResponse.MUTE_ROLE_NOT_MANAGEABLE; + } return true; } /** + * Options for creating a modlog entry. + */ +export interface CreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The client. + */ + client: Client; + + /** + * The user that a modlog entry is created for. + */ + user: GuildMemberResolvable; + + /** + * The moderator that created the modlog entry. + */ + moderator: GuildMemberResolvable; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; +} + +/** * Creates a modlog entry for a punishment. * @param options Options for creating a modlog entry. * @param getCaseNumber Whether or not to get the case number of the entry. @@ -166,6 +331,26 @@ export async function createModLogEntry( } /** + * Simple options for creating a modlog entry. + */ +export interface SimpleCreateModLogEntryOptions extends BaseCreateModLogEntryOptions { + /** + * The user that a modlog entry is created for. + */ + user: Snowflake; + + /** + * The moderator that created the modlog entry. + */ + moderator: Snowflake; + + /** + * The guild that the punishment is created for. + */ + guild: Snowflake; +} + +/** * Creates a modlog entry with already resolved ids. * @param options Options for creating a modlog entry. * @param getCaseNumber Whether or not to get the case number of the entry. @@ -192,6 +377,7 @@ export async function createModLogEntrySimple( evidence: options.evidence, hidden: options.hidden ?? false }); + const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { await options.client.utils.handleError('createModLogEntry', e); return null; @@ -206,6 +392,41 @@ export async function createModLogEntrySimple( } /** + * Options for creating a punishment entry. + */ +export interface CreatePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is created for. + */ + user: GuildMemberResolvable; + + /** + * The length of time the punishment lasts for. + */ + duration: number | undefined; + + /** + * The guild that the punishment is created for. + */ + guild: GuildResolvable; + + /** + * The id of the modlog that is linked to the punishment entry. + */ + modlog: string; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** * Creates a punishment entry. * @param options Options for creating the punishment entry. * @returns The database entry, or null if no entry is created. @@ -221,6 +442,7 @@ export async function createPunishmentEntry(options: CreatePunishmentEntryOption ? { 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 options.client.utils.handleError('createPunishmentEntry', e); return null; @@ -228,6 +450,31 @@ export async function createPunishmentEntry(options: CreatePunishmentEntryOption } /** + * Options for removing a punishment entry. + */ +export interface RemovePunishmentEntryOptions extends BaseOptions { + /** + * The type of punishment. + */ + type: 'mute' | 'ban' | 'role' | 'block'; + + /** + * The user that the punishment is destroyed for. + */ + user: GuildMemberResolvable; + + /** + * The guild that the punishment was in. + */ + guild: GuildResolvable; + + /** + * Extra information for the punishment. The role for role punishments and the channel for blocks. + */ + extraInfo?: Snowflake; +} + +/** * Destroys a punishment entry. * @param options Options for destroying the punishment entry. * @returns Whether or not the entry was destroyed. @@ -250,6 +497,7 @@ export async function removePunishmentEntry(options: RemovePunishmentEntryOption await options.client.utils.handleError('removePunishmentEntry', e); success = false; }); + if (entries) { const promises = entries.map(async (entry) => entry.destroy().catch(async (e) => { @@ -270,212 +518,14 @@ export async function removePunishmentEntry(options: RemovePunishmentEntryOption */ function findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { const typeMap = { - ['mute']: ActivePunishmentType.MUTE, - ['ban']: ActivePunishmentType.BAN, - ['role']: ActivePunishmentType.ROLE, - ['block']: ActivePunishmentType.BLOCK + mute: ActivePunishmentType.Mute, + ban: ActivePunishmentType.Ban, + role: ActivePunishmentType.Role, + block: ActivePunishmentType.Block }; return typeMap[type]; } -export function punishmentToPresentTense(punishment: PunishmentTypeDM): PunishmentTypePresent { - return punishMap[punishment]; -} - -export function 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. - */ -export async function punishDM(options: PunishDMOptions): Promise<boolean> { - const ending = await options.guild.getSetting('punishmentEnding'); - const dmEmbed = - ending && ending.length && options.sendFooter - ? new EmbedBuilder().setDescription(ending).setColor(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 ${format.input(options.guild.name)} `; - if (options.duration !== null && options.duration !== undefined) - content += options.duration ? `for ${humanizeDuration(options.duration)} ` : 'permanently '; - const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided'; - content += `for ${format.input(reason)}.`; - - let components; - if (appealsEnabled && options.modlog) - components = [ - new ActionRowBuilder<ButtonBuilder>({ - components: [ - new ButtonBuilder({ - customId: `appeal;${punishmentToPresentTense(options.punishment)};${ - options.guild.id - };${options.client.users.resolveId(options.user)};${options.modlog}`, - style: ButtonStyle.Primary, - label: 'Appeal' - }).toJSON() - ] - }) - ]; - - const dmSuccess = await options.client.users - .send(options.user, { - content, - embeds: dmEmbed ? [dmEmbed] : undefined, - components - }) - .catch(() => false); - return !!dmSuccess; -} - -interface BaseCreateModLogEntryOptions extends BaseOptions { - /** - * The type of modlog entry. - */ - type: ModLogType; - - /** - * The reason for the punishment. - */ - reason: string | undefined | null; - - /** - * The duration of the punishment. - */ - duration?: number; - - /** - * Whether the punishment is a pseudo punishment. - */ - pseudo?: boolean; - - /** - * The evidence for the punishment. - */ - evidence?: string; - - /** - * Makes the modlog entry hidden. - */ - hidden?: boolean; -} - -/** - * Options for creating a modlog entry. - */ -export interface CreateModLogEntryOptions extends BaseCreateModLogEntryOptions { - /** - * The client. - */ - client: Client; - - /** - * The user that a modlog entry is created for. - */ - user: GuildMemberResolvable; - - /** - * The moderator that created the modlog entry. - */ - moderator: GuildMemberResolvable; - - /** - * The guild that the punishment is created for. - */ - guild: GuildResolvable; -} - -/** - * Simple options for creating a modlog entry. - */ -export interface SimpleCreateModLogEntryOptions extends BaseCreateModLogEntryOptions { - /** - * The user that a modlog entry is created for. - */ - user: Snowflake; - - /** - * The moderator that created the modlog entry. - */ - moderator: Snowflake; - - /** - * The guild that the punishment is created for. - */ - guild: Snowflake; -} - -/** - * Options for creating a punishment entry. - */ -export interface CreatePunishmentEntryOptions extends BaseOptions { - /** - * The type of punishment. - */ - type: 'mute' | 'ban' | 'role' | 'block'; - - /** - * The user that the punishment is created for. - */ - user: GuildMemberResolvable; - - /** - * The length of time the punishment lasts for. - */ - duration: number | undefined; - - /** - * The guild that the punishment is created for. - */ - guild: GuildResolvable; - - /** - * The id of the modlog that is linked to the punishment entry. - */ - modlog: string; - - /** - * Extra information for the punishment. The role for role punishments and the channel for blocks. - */ - extraInfo?: Snowflake; -} - -/** - * Options for removing a punishment entry. - */ -export interface RemovePunishmentEntryOptions extends BaseOptions { - /** - * The type of punishment. - */ - type: 'mute' | 'ban' | 'role' | 'block'; - - /** - * The user that the punishment is destroyed for. - */ - user: GuildMemberResolvable; - - /** - * The guild that the punishment was in. - */ - guild: GuildResolvable; - - /** - * Extra information for the punishment. The role for role punishments and the channel for blocks. - */ - extraInfo?: Snowflake; -} - /** * Options for sending a user a punishment dm. */ @@ -498,7 +548,7 @@ export interface PunishDMOptions extends BaseOptions { /** * The punishment that the user has received. */ - punishment: PunishmentTypeDM; + punishment: Action; /** * The reason the user's punishment. @@ -522,35 +572,59 @@ export interface PunishDMOptions extends BaseOptions { channel?: Snowflake; } -interface BaseOptions { - /** - * The client. - */ - client: Client; -} +/** + * Notifies the specified user of their punishment. + * @param options Options for notifying the user. + * @returns Whether or not the dm was successfully sent. + */ +export async function punishDM(options: PunishDMOptions): Promise<boolean> { + const ending = await options.guild.getSetting('punishmentEnding'); + const dmEmbed = + ending && ending.length && options.sendFooter + ? new EmbedBuilder().setDescription(ending).setColor(colors.newBlurple) + : undefined; + + const appealsEnabled = + (await options.guild.hasFeature('punishmentAppeals')) && Boolean(await options.guild.getLogChannel('appeals')); -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}`; + let content = `You have been ${options.punishment} `; + if ([Action.Block, Action.Unblock].includes(options.punishment)) { + assert(options.channel); + content += `from <#${options.channel}> `; + } + content += `in ${format.input(options.guild.name)} `; + if (options.duration !== null && options.duration !== undefined) { + content += options.duration ? `for ${humanizeDuration(options.duration)} ` : 'permanently '; + } + const reason = options.reason?.trim() ? options.reason?.trim() : 'No reason provided'; + content += `for ${format.input(reason)}.`; + + let components; + if (appealsEnabled && options.modlog) { + const punishment = options.punishment; + const guildId = options.guild.id; + const userId = options.client.users.resolveId(options.user); + const modlogCase = options.modlog; + + components = [ + new ActionRowBuilder<ButtonBuilder>({ + components: [ + new ButtonBuilder({ + customId: `appeal_attempt;${Action[punishment]};${guildId};${userId};${modlogCase}`, + style: ButtonStyle.Primary, + label: 'Appeal Punishment' + }) + ] + }) + ]; + } + + const dmSuccess = await options.client.users + .send(options.user, { + content, + embeds: dmEmbed ? [dmEmbed] : undefined, + components + }) + .catch(() => false); + return !!dmSuccess; +} diff --git a/lib/common/tags.ts b/lib/common/tags.ts index 4af8783..826b820 100644 --- a/lib/common/tags.ts +++ b/lib/common/tags.ts @@ -1,5 +1,5 @@ -/* these functions are adapted from the common-tags npm package which is licensed under the MIT license */ -/* the JSDOCs are adapted from the @types/common-tags npm package which is licensed under the MIT license */ +/* The stripIndent, stripIndents, and format functions are adapted from the common-tags npm package which is licensed under the MIT license */ +/* The JSDOCs for said functions are adapted from the @types/common-tags npm package which is licensed under the MIT license */ /** * Strips the **initial** indentation from the beginning of each line in a multiline string. @@ -32,3 +32,131 @@ function format(strings: TemplateStringsArray, ...expressions: any[]) { .replace(/^\n/, ''); return str; } + +export function commas(strings: TemplateStringsArray, ...expressions: any[]) { + const str = strings + .reduce((result, string, index) => ''.concat(result, localString(expressions[index - 1]), string)) + .replace(/[^\S\n]+$/gm, '') + .replace(/^\n/, ''); + + return str; +} + +function localString(val: any) { + return typeof val === 'number' ? val.toLocaleString() : val; +} + +export function commasStripIndents(strings: TemplateStringsArray, ...expressions: any[]) { + return stripIndents`${commas(strings, ...expressions)}`; +} + +function splitByNewline(strings: TemplateStringsArray, ...expressions: any[]): any[][] { + const ret: any[][] = []; + + let current: any[] = []; + + for (let i = 0; i < strings.length; i++) { + const string = strings[i]; + if (string.includes('\n')) { + // divide the string by newlines + const [first, ...rest] = string.split('\n'); + + // no point add an empty string + if (first !== '') { + // complete the current line + current.push(first); + } + + // ignore empty first line + if (i !== 0 && current.length !== 1 && current[1] !== '') { + // add the current line to the list of lines + ret.push(current); + } + + // handle multiple newlines + if (rest.length > 1) { + // loop though everything but the final element + for (const line of rest.slice(0, -1)) { + ret.push([line]); + } + } + + // since there are no more empty newlines, add to the current line so that expressions can be added + const last = rest[rest.length - 1]; + current = [last]; + } else { + // if there are no newlines, just add to the current line + current.push(string); + } + + // now add the expression + if (i < expressions.length) current.push(expressions[i]); + } + + // add the final line + ret.push(current); + + return ret; +} + +/** + * Creates information fields for embeds. Commas are added to numbers. + * Lines are ignored if the expression is `null`, `undefined`, or `false`. + * Additionally, leading whitespace is removed. If the first line is empty, + * it is ignored. + * @example + * ```ts + * const value = 'value'; + * const condition = false; + * + * embedField` + * Header ${value} + * Another Header ${condition && 50} + * A Third Header ${50000}` + * + * // **Header:** value + * // **A Third Header:** 50,000 + * ``` + */ +export function embedField(strings: TemplateStringsArray, ...expressions: any[]) { + const lines: any[][] = splitByNewline(strings, ...expressions); + + // loop through each line and remove any leading whitespace + for (let i = 0; i < lines.length; i++) { + lines[i][0] = lines[i][0].replace(/^[^\S\n]+/gm, ''); + } + + const result: string[] = []; + + out: for (let i = 0; i < lines.length; i++) { + // eslint-disable-next-line prefer-const + let [header, ...rest] = lines[i]; + + header = `**${header.trim()}:**`; + + const lineContent: string[] = []; + + for (let i = 0; i < rest.length; i++) { + const value = rest[i]; + if (typeof value === 'string') { + lineContent.push(value); + } else if (typeof value === 'number') { + // add commas to numbers + lineContent.push(value.toLocaleString()); + } else if (value === null || value === undefined || value === false) { + if (i === 0) { + // ignore this line + continue out; + } else { + throw new Error('Null or false values can only be used as the first expression in a line.'); + } + } else { + lineContent.push(value.toString()); + } + } + + result.push(`${header} ${lineContent.join('')}`); + } + + return result.join('\n'); +} |