import { ActivePunishment, ActivePunishmentType, Guild, ModLog, type BushGuild, type BushGuildMember, type BushGuildMemberResolvable, type BushGuildResolvable, type BushUserResolvable, type ModLogType } from '#lib'; 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. */ export class Moderation { /** * Checks if a moderator can perform a moderation action on another user. * @param moderator The person trying to perform the action. * @param victim The person getting punished. * @param type The type of punishment - used to format the response. * @param checkModerator Whether or not to check if the victim is a moderator. * @param force Override permissions checks. * @returns `true` if the moderator can perform the action otherwise a reason why they can't. */ public static async permissionCheck( moderator: BushGuildMember, victim: BushGuildMember, type: | 'mute' | 'unmute' | 'warn' | 'kick' | 'ban' | 'unban' | 'add a punishment role to' | 'remove a punishment role from' | 'block' | 'unblock' | 'timeout' | 'untimeout', checkModerator = true, force = false ): Promise { if (force) return true; // If the victim is not in the guild anymore it will be undefined if ((!victim || !victim.guild) && !['ban', 'unban'].includes(type)) return true; if (moderator.guild.id !== victim.guild.id) { throw new Error('moderator and victim not in same guild'); } const isOwner = moderator.guild.ownerId === moderator.id; if (moderator.id === victim.id && !type.startsWith('un')) { return `${util.emojis.error} You cannot ${type} yourself.`; } if ( moderator.roles.highest.position <= victim.roles.highest.position && !isOwner && !(type.startsWith('un') && moderator.id === victim.id) ) { return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they have higher or equal role hierarchy as you do.`; } if ( victim.roles.highest.position >= victim.guild.me!.roles.highest.position && !(type.startsWith('un') && moderator.id === victim.id) ) { return `${util.emojis.error} You cannot ${type} **${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) ) { if (await moderator.guild.hasFeature('modsCanPunishMods')) { return true; } else { return `${util.emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`; } } return true; } /** * 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. * @returns An object with the modlog and the case number. */ public static async createModLogEntry( options: CreateModLogEntryOptions, getCaseNumber = false ): Promise<{ log: ModLog | null; caseNum: number | null }> { const user = (await util.resolveNonCachedUser(options.user))!.id; const moderator = (await util.resolveNonCachedUser(options.moderator))!.id; const guild = client.guilds.resolveId(options.guild)!; const duration = options.duration ? options.duration : undefined; // If guild does not exist create it so the modlog can reference a guild. await Guild.findOrCreate({ where: { id: guild }, defaults: { id: guild } }); const modLogEntry = ModLog.build({ type: options.type, user, moderator, reason: options.reason, duration: duration, guild, pseudo: options.pseudo ?? false, evidence: options.evidence, hidden: options.hidden ?? false }); const saveResult: ModLog | null = await modLogEntry.save().catch(async (e) => { await util.handleError('createModLogEntry', e); return null; }); if (!getCaseNumber) return { log: saveResult, caseNum: null }; const caseNum = (await ModLog.findAll({ where: { type: options.type, user: user, guild: guild, hidden: 'false' } }))?.length; return { log: saveResult, caseNum }; } /** * Creates a punishment entry. * @param options Options for creating the punishment entry. * @returns The database entry, or null if no entry is created. */ public static async createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise { const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined; const user = (await util.resolveNonCachedUser(options.user))!.id; const guild = client.guilds.resolveId(options.guild)!; const type = this.findTypeEnum(options.type)!; const entry = ActivePunishment.build( options.extraInfo ? { 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 util.handleError('createPunishmentEntry', e); return null; }); } /** * Destroys a punishment entry. * @param options Options for destroying the punishment entry. * @returns Whether or not the entry was destroyed. */ public static async removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise { const user = await util.resolveNonCachedUser(options.user); const guild = client.guilds.resolveId(options.guild); const type = this.findTypeEnum(options.type); if (!user || !guild) return false; let success = true; const entries = await ActivePunishment.findAll({ // finding all cases of a certain type incase there were duplicates or something where: options.extraInfo ? { user: user.id, guild: guild, type, extraInfo: options.extraInfo } : { user: user.id, guild: guild, type } }).catch(async (e) => { await util.handleError('removePunishmentEntry', e); success = false; }); if (entries) { const promises = entries.map(async (entry) => entry.destroy().catch(async (e) => { await util.handleError('removePunishmentEntry', e); success = false; }) ); await Promise.all(promises); } return success; } /** * Returns the punishment type enum for the given type. * @param type The type of the punishment. * @returns The punishment type enum. */ private static findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') { const typeMap = { ['mute']: ActivePunishmentType.MUTE, ['ban']: ActivePunishmentType.BAN, ['role']: ActivePunishmentType.ROLE, ['block']: ActivePunishmentType.BLOCK }; 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 { const ending = await options.guild.getSetting('punishmentEnding'); const dmEmbed = ending && ending.length && options.sendFooter ? 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, embeds: dmEmbed ? [dmEmbed] : undefined, components }) .catch(() => false); return !!dmSuccess; } } /** * Options for creating a modlog entry. */ export interface CreateModLogEntryOptions { /** * The type of modlog entry. */ type: ModLogType; /** * The user that a modlog entry is created for. */ user: BushGuildMemberResolvable; /** * The moderator that created the modlog entry. */ moderator: BushGuildMemberResolvable; /** * The reason for the punishment. */ reason: string | undefined | null; /** * The duration of the punishment. */ duration?: number; /** * The guild that the punishment is created for. */ guild: BushGuildResolvable; /** * 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 punishment entry. */ export interface CreatePunishmentEntryOptions { /** * The type of punishment. */ type: 'mute' | 'ban' | 'role' | 'block'; /** * The user that the punishment is created for. */ user: BushGuildMemberResolvable; /** * The length of time the punishment lasts for. */ duration: number | undefined; /** * The guild that the punishment is created for. */ guild: BushGuildResolvable; /** * 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 { /** * The type of punishment. */ type: 'mute' | 'ban' | 'role' | 'block'; /** * The user that the punishment is destroyed for. */ user: BushGuildMemberResolvable; /** * The guild that the punishment was in. */ guild: BushGuildResolvable; /** * 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. */ 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. */ guild: BushGuild; /** * The user that is being punished. */ user: BushUserResolvable; /** * The punishment that the user has received. */ punishment: PunishmentTypeDM; /** * The reason the user's punishment. */ reason?: string; /** * The duration of the punishment. */ duration?: number; /** * Whether or not to send the guild's punishment footer with the dm. * @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}`;