aboutsummaryrefslogtreecommitdiff
path: root/lib/common/Moderation.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/common/Moderation.ts')
-rw-r--r--lib/common/Moderation.ts556
1 files changed, 556 insertions, 0 deletions
diff --git a/lib/common/Moderation.ts b/lib/common/Moderation.ts
new file mode 100644
index 0000000..60e32c0
--- /dev/null
+++ b/lib/common/Moderation.ts
@@ -0,0 +1,556 @@
+import {
+ ActivePunishment,
+ ActivePunishmentType,
+ baseMuteResponse,
+ colors,
+ emojis,
+ format,
+ Guild as GuildDB,
+ humanizeDuration,
+ ModLog,
+ permissionsResponse,
+ type ModLogType,
+ type ValueOf
+} from '#lib';
+import assert from 'assert/strict';
+import {
+ ActionRowBuilder,
+ ButtonBuilder,
+ ButtonStyle,
+ Client,
+ EmbedBuilder,
+ PermissionFlagsBits,
+ type Guild,
+ type GuildMember,
+ type GuildMemberResolvable,
+ type GuildResolvable,
+ type Snowflake,
+ 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'
+}
+enum reversedPunishMap {
+ 'warn' = 'warned',
+ 'mute' = 'muted',
+ 'unmute' = 'unmuted',
+ 'kick' = 'kicked',
+ 'ban' = 'banned',
+ 'unban' = 'unbanned',
+ 'timeout' = 'timedout',
+ 'untimeout' = 'untimedout',
+ 'block' = 'blocked',
+ 'unblock' = 'unblocked'
+}
+
+/**
+ * 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.
+ */
+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',
+ checkModerator = true,
+ force = false
+): Promise<true | string> {
+ 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 `${emojis.error} You cannot ${type} yourself.`;
+ }
+ if (
+ moderator.roles.highest.position <= victim.roles.highest.position &&
+ !isOwner &&
+ !(type.startsWith('un') && moderator.id === victim.id)
+ ) {
+ return `${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.members.me!.roles.highest.position &&
+ !(type.startsWith('un') && moderator.id === victim.id)
+ ) {
+ return `${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 `${emojis.error} You cannot ${type} **${victim.user.tag}** because they are a moderator.`;
+ }
+ }
+ return true;
+}
+
+/**
+ * Performs permission checks that are required in order to (un)mute a member.
+ * @param guild The guild to check the mute permissions in.
+ * @returns A {@link MuteResponse} or true if nothing failed.
+ */
+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;
+ 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)
+ return baseMuteResponse.MUTE_ROLE_NOT_MANAGEABLE;
+
+ 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.
+ */
+export async function createModLogEntry(
+ options: CreateModLogEntryOptions,
+ getCaseNumber = false
+): Promise<{ log: ModLog | null; caseNum: number | null }> {
+ const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id;
+ const moderator = (await options.client.utils.resolveNonCachedUser(options.moderator))!.id;
+ const guild = options.client.guilds.resolveId(options.guild)!;
+
+ return createModLogEntrySimple(
+ {
+ ...options,
+ user: user,
+ moderator: moderator,
+ guild: guild
+ },
+ getCaseNumber
+ );
+}
+
+/**
+ * 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.
+ * @returns An object with the modlog and the case number.
+ */
+export async function createModLogEntrySimple(
+ options: SimpleCreateModLogEntryOptions,
+ getCaseNumber = false
+): Promise<{ log: ModLog | null; caseNum: number | null }> {
+ // If guild does not exist create it so the modlog can reference a guild.
+ await GuildDB.findOrCreate({
+ where: { id: options.guild },
+ defaults: { id: options.guild }
+ });
+
+ const modLogEntry = ModLog.build({
+ type: options.type,
+ user: options.user,
+ moderator: options.moderator,
+ reason: options.reason,
+ duration: options.duration ? options.duration : undefined,
+ guild: options.guild,
+ pseudo: options.pseudo ?? false,
+ 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;
+ });
+
+ if (!getCaseNumber) return { log: saveResult, caseNum: null };
+
+ const caseNum = (
+ await ModLog.findAll({ where: { type: options.type, user: options.user, guild: options.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.
+ */
+export async function createPunishmentEntry(options: CreatePunishmentEntryOptions): Promise<ActivePunishment | null> {
+ const expires = options.duration ? new Date(+new Date() + options.duration ?? 0) : undefined;
+ const user = (await options.client.utils.resolveNonCachedUser(options.user))!.id;
+ const guild = options.client.guilds.resolveId(options.guild)!;
+ const type = 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 options.client.utils.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.
+ */
+export async function removePunishmentEntry(options: RemovePunishmentEntryOptions): Promise<boolean> {
+ const user = await options.client.utils.resolveNonCachedUser(options.user);
+ const guild = options.client.guilds.resolveId(options.guild);
+ const type = 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 options.client.utils.handleError('removePunishmentEntry', e);
+ success = false;
+ });
+ if (entries) {
+ const promises = entries.map(async (entry) =>
+ entry.destroy().catch(async (e) => {
+ await options.client.utils.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.
+ */
+function findTypeEnum(type: 'mute' | 'ban' | 'role' | 'block') {
+ const typeMap = {
+ ['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.
+ */
+export interface PunishDMOptions extends BaseOptions {
+ /**
+ * The modlog case id so the user can make an appeal.
+ */
+ modlog?: string;
+
+ /**
+ * The guild that the punishment is taking place in.
+ */
+ guild: Guild;
+
+ /**
+ * The user that is being punished.
+ */
+ user: UserResolvable;
+
+ /**
+ * 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;
+}
+
+interface BaseOptions {
+ /**
+ * The client.
+ */
+ client: Client;
+}
+
+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}`;