import { ActivePunishment, ActivePunishmentType, Guild, ModLog, type BushGuild, type BushGuildMember, type BushGuildMemberResolvable, type BushGuildResolvable, type BushUserResolvable, type ModLogType } from '#lib'; import { MessageEmbed, type Snowflake } from 'discord.js'; /** * 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('MANAGE_MESSAGES') && !(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 async punishDM(options: PunishDMOptions): Promise { const ending = await options.guild.getSetting('punishmentEnding'); const dmEmbed = ending && ending.length && options.sendFooter ? new MessageEmbed().setDescription(ending).setColor(util.colors.newBlurple) : undefined; 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 }) .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 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: string; /** * 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; }