diff options
Diffstat (limited to 'lib')
35 files changed, 1008 insertions, 504 deletions
diff --git a/lib/automod/AutomodShared.ts b/lib/automod/AutomodShared.ts index 29b0536..48217dd 100644 --- a/lib/automod/AutomodShared.ts +++ b/lib/automod/AutomodShared.ts @@ -162,7 +162,7 @@ export async function handleAutomodInteraction(interaction: ButtonInteraction) { ephemeral: true }); - const check = victim ? await Moderation.permissionCheck(moderator, victim, 'ban', true) : true; + const check = victim ? await Moderation.permissionCheck(moderator, victim, Moderation.Action.Ban, true) : true; if (check !== true) return interaction.reply({ content: check, ephemeral: true }); const result = await interaction.guild?.customBan({ @@ -203,7 +203,7 @@ export async function handleAutomodInteraction(interaction: ButtonInteraction) { ephemeral: true }); - const check = await Moderation.permissionCheck(moderator, victim, 'unmute', true); + const check = await Moderation.permissionCheck(moderator, victim, Moderation.Action.Unmute, true); if (check !== true) return interaction.reply({ content: check, ephemeral: true }); const check2 = await Moderation.checkMutePermissions(interaction.guild); 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) => { |
