aboutsummaryrefslogtreecommitdiff
path: root/lib/common
diff options
context:
space:
mode:
Diffstat (limited to 'lib/common')
-rw-r--r--lib/common/Appeals.ts273
-rw-r--r--lib/common/ButtonPaginator.ts6
-rw-r--r--lib/common/ConfirmationPrompt.ts12
-rw-r--r--lib/common/DeleteButton.ts8
-rw-r--r--lib/common/HighlightManager.ts3
-rw-r--r--lib/common/Moderation.ts668
-rw-r--r--lib/common/tags.ts132
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');
+}