diff options
author | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-10-03 22:57:40 -0400 |
---|---|---|
committer | IRONM00N <64110067+IRONM00N@users.noreply.github.com> | 2022-10-03 22:57:40 -0400 |
commit | 612ed820a0600ec11ed642005377cd7f5a8a8b77 (patch) | |
tree | 6bca4e7268fd0063ff53cf64fa44df62a23dba50 /lib | |
parent | ed98ff7e2679f362f2657e77a6cf8dd3ce9b3d43 (diff) | |
download | tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.tar.gz tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.tar.bz2 tanzanite-612ed820a0600ec11ed642005377cd7f5a8a8b77.zip |
wip
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) => { 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'); +} diff --git a/lib/extensions/discord-akairo/BotCommand.ts b/lib/extensions/discord-akairo/BotCommand.ts index 11a8bad..a975667 100644 --- a/lib/extensions/discord-akairo/BotCommand.ts +++ b/lib/extensions/discord-akairo/BotCommand.ts @@ -11,14 +11,6 @@ import type { import { Command, CommandArguments, - type AkairoApplicationCommandAutocompleteOption, - type AkairoApplicationCommandChannelOptionData, - type AkairoApplicationCommandChoicesData, - type AkairoApplicationCommandNonOptionsData, - type AkairoApplicationCommandNumericOptionData, - type AkairoApplicationCommandOptionData, - type AkairoApplicationCommandSubCommandData, - type AkairoApplicationCommandSubGroupData, type ArgumentMatch, type ArgumentOptions, type ArgumentType, @@ -29,8 +21,9 @@ import { type ContextMenuCommand, type SlashOption, type SlashResolveType -} from 'discord-akairo'; +} from '@notenoughupdates/discord-akairo'; import { + ApplicationCommandChannelOption, PermissionsBitField, type ApplicationCommandOptionChoiceData, type ApplicationCommandOptionType, @@ -39,7 +32,7 @@ import { type Snowflake, type User } from 'discord.js'; -import _ from 'lodash'; +import { camelCase } from 'lodash-es'; import { SlashMessage } from './SlashMessage.js'; export interface OverriddenBaseArgumentType extends BaseArgumentType { @@ -89,7 +82,7 @@ interface BaseBotArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt' /** * The type used for slash commands. Set to false to disable this argument for slash commands. */ - slashType: AkairoApplicationCommandOptionData['type'] | false; + slashType: SlashOption['type'] | false; /** * Allows you to get a discord resolved object @@ -111,7 +104,7 @@ interface BaseBotArgumentOptions extends Omit<ArgumentOptions, 'type' | 'prompt' /** * When the option type is channel, the allowed types of channels that can be selected */ - channelTypes?: AkairoApplicationCommandChannelOptionData['channelTypes']; + channelTypes?: ApplicationCommandChannelOption['channelTypes']; /** * The minimum value for an {@link ApplicationCommandOptionType.Integer Integer} or {@link ApplicationCommandOptionType.Number Number} option @@ -508,8 +501,11 @@ export abstract class BotCommand extends Command { (options_.slash || options_.slashOnly) && arg.slashType !== false ) { + // credit to https://dev.to/lucianbc/union-type-merging-in-typescript-9al + type AllKeys<T> = T extends any ? keyof T : never; + const newArg: { - [key in SlashOptionKeys]?: any; + [key in AllKeys<SlashOption>]?: any; } = { name: arg.id, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing @@ -560,7 +556,7 @@ export abstract class BotCommand extends Command { }); for (const arg of combined) { - const name = _.camelCase('id' in arg ? arg.id : arg.name), + const name = camelCase('id' in arg ? arg.id : arg.name), description = arg.description || '*No description provided.*', optional = arg.optional ?? false, autocomplete = arg.autocomplete ?? false, @@ -607,15 +603,6 @@ export abstract class BotCommand extends Command { public abstract override exec(message: CommandMessage | SlashMessage, args: CommandArguments): any; } -type SlashOptionKeys = - | keyof AkairoApplicationCommandSubGroupData - | keyof AkairoApplicationCommandNonOptionsData - | keyof AkairoApplicationCommandChannelOptionData - | keyof AkairoApplicationCommandChoicesData - | keyof AkairoApplicationCommandAutocompleteOption - | keyof AkairoApplicationCommandNumericOptionData - | keyof AkairoApplicationCommandSubCommandData; - interface PseudoArguments extends BaseBotArgumentType { boolean: boolean; flag: boolean; diff --git a/lib/extensions/discord-akairo/BotCommandHandler.ts b/lib/extensions/discord-akairo/BotCommandHandler.ts index e9b509f..c1415e3 100644 --- a/lib/extensions/discord-akairo/BotCommandHandler.ts +++ b/lib/extensions/discord-akairo/BotCommandHandler.ts @@ -1,5 +1,10 @@ -import type { BotCommand, CommandMessage, SlashMessage } from '#lib'; -import { CommandHandler, CommandHandlerEvents, type Category, type CommandHandlerOptions } from 'discord-akairo'; +import type { BotCommand, CommandMessage, SlashMessage, TanzaniteClient } from '#lib'; +import { + CommandHandler, + CommandHandlerEvents, + type Category, + type CommandHandlerOptions +} from '@notenoughupdates/discord-akairo'; import { GuildMember, PermissionResolvable, type Collection, type Message, type PermissionsString } from 'discord.js'; import { CommandHandlerEvent } from '../../utils/Constants.js'; @@ -41,6 +46,8 @@ export interface BotCommandHandlerEvents extends CommandHandlerEvents { } export class BotCommandHandler extends CommandHandler { + public declare readonly client: TanzaniteClient; + public declare modules: Collection<string, BotCommand>; public declare categories: Collection<string, Category<string, BotCommand>>; diff --git a/lib/extensions/discord-akairo/BotInhibitor.ts b/lib/extensions/discord-akairo/BotInhibitor.ts index 8892b8b..8a53e0d 100644 --- a/lib/extensions/discord-akairo/BotInhibitor.ts +++ b/lib/extensions/discord-akairo/BotInhibitor.ts @@ -1,8 +1,10 @@ -import type { BotCommand, CommandMessage, InhibitorReason, InhibitorType, SlashMessage } from '#lib'; -import { Inhibitor, InhibitorOptions } from 'discord-akairo'; +import type { BotCommand, CommandMessage, InhibitorReason, InhibitorType, SlashMessage, TanzaniteClient } from '#lib'; +import { Inhibitor, InhibitorOptions } from '@notenoughupdates/discord-akairo'; import { Message } from 'discord.js'; export abstract class BotInhibitor extends Inhibitor { + public declare readonly client: TanzaniteClient; + public constructor(id: InhibitorReason, options?: BotInhibitorOptions) { super(id, options); } diff --git a/lib/extensions/discord-akairo/BotInhibitorHandler.ts b/lib/extensions/discord-akairo/BotInhibitorHandler.ts index c6f318d..05caca6 100644 --- a/lib/extensions/discord-akairo/BotInhibitorHandler.ts +++ b/lib/extensions/discord-akairo/BotInhibitorHandler.ts @@ -1,3 +1,6 @@ -import { InhibitorHandler } from 'discord-akairo'; +import { InhibitorHandler } from '@notenoughupdates/discord-akairo'; +import { TanzaniteClient } from './TanzaniteClient.js'; -export class BotInhibitorHandler extends InhibitorHandler {} +export class BotInhibitorHandler extends InhibitorHandler { + public declare readonly client: TanzaniteClient; +} diff --git a/lib/extensions/discord-akairo/BotListener.ts b/lib/extensions/discord-akairo/BotListener.ts index 4f760e2..85acce3 100644 --- a/lib/extensions/discord-akairo/BotListener.ts +++ b/lib/extensions/discord-akairo/BotListener.ts @@ -1,6 +1,9 @@ -import { Listener, type ListenerOptions } from 'discord-akairo'; +import { Listener, type ListenerOptions } from '@notenoughupdates/discord-akairo'; +import { TanzaniteClient } from './TanzaniteClient.js'; export abstract class BotListener extends Listener { + public declare readonly client: TanzaniteClient<boolean>; + public constructor(id: string, options: BotListenerOptions) { super(id, options); } diff --git a/lib/extensions/discord-akairo/BotListenerHandler.ts b/lib/extensions/discord-akairo/BotListenerHandler.ts index bc14a53..6a1ad4c 100644 --- a/lib/extensions/discord-akairo/BotListenerHandler.ts +++ b/lib/extensions/discord-akairo/BotListenerHandler.ts @@ -1,4 +1,4 @@ -import { ListenerHandler } from 'discord-akairo'; +import { ListenerHandler } from '@notenoughupdates/discord-akairo'; import type readline from 'readline'; import { TanzaniteClient } from './TanzaniteClient.js'; diff --git a/lib/extensions/discord-akairo/BotTask.ts b/lib/extensions/discord-akairo/BotTask.ts index 09b30ed..fd0dc2e 100644 --- a/lib/extensions/discord-akairo/BotTask.ts +++ b/lib/extensions/discord-akairo/BotTask.ts @@ -1,3 +1,3 @@ -import { Task } from 'discord-akairo'; +import { Task } from '@notenoughupdates/discord-akairo'; export abstract class BotTask extends Task {} diff --git a/lib/extensions/discord-akairo/BotTaskHandler.ts b/lib/extensions/discord-akairo/BotTaskHandler.ts index b522f2c..1b4b5bd 100644 --- a/lib/extensions/discord-akairo/BotTaskHandler.ts +++ b/lib/extensions/discord-akairo/BotTaskHandler.ts @@ -1,3 +1,3 @@ -import { TaskHandler } from 'discord-akairo'; +import { TaskHandler } from '@notenoughupdates/discord-akairo'; export class BotTaskHandler extends TaskHandler {} diff --git a/lib/extensions/discord-akairo/SlashMessage.ts b/lib/extensions/discord-akairo/SlashMessage.ts index 0a6669b..b93f25f 100644 --- a/lib/extensions/discord-akairo/SlashMessage.ts +++ b/lib/extensions/discord-akairo/SlashMessage.ts @@ -1,3 +1,3 @@ -import { AkairoMessage } from 'discord-akairo'; +import { AkairoMessage } from '@notenoughupdates/discord-akairo'; export class SlashMessage extends AkairoMessage {} diff --git a/lib/extensions/discord-akairo/TanzaniteClient.ts b/lib/extensions/discord-akairo/TanzaniteClient.ts index ac09aea..a8346ba 100644 --- a/lib/extensions/discord-akairo/TanzaniteClient.ts +++ b/lib/extensions/discord-akairo/TanzaniteClient.ts @@ -11,8 +11,20 @@ import { snowflake } from '#args'; import type { Config } from '#config'; -import { patch, type PatchedElements } from '@notenoughupdates/events-intercept'; -import * as Sentry from '@sentry/node'; +import { + ActivePunishment, + Global, + Guild as GuildModel, + GuildCount, + Highlight, + Level, + MemberCount, + ModLog, + Reminder, + Shared, + Stat, + StickyRole +} from '#lib/models/index.js'; import { AkairoClient, ArgumentTypeCaster, @@ -20,7 +32,9 @@ import { version as akairoVersion, type ArgumentPromptData, type OtherwiseContentSupplier -} from 'discord-akairo'; +} from '@notenoughupdates/discord-akairo'; +import * as Sentry from '@sentry/node'; +import { patch, type PatchedElements } from '@tanzanite/events-intercept'; import { ActivityType, GatewayIntentBits, @@ -32,33 +46,19 @@ import { type Awaitable, type If, type Message, - type MessageOptions, + type MessageCreateOptions, type Snowflake, type UserResolvable } from 'discord.js'; -import type EventEmitter from 'events'; import { google } from 'googleapis'; -import path from 'path'; -import readline from 'readline'; +import { type EventEmitter } from 'node:events'; +import path from 'node:path'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; import { Options as SequelizeOptions, Sequelize, Sequelize as SequelizeType } from 'sequelize'; -import { fileURLToPath } from 'url'; import { tinyColor } from '../../arguments/tinyColor.js'; import { BotCache } from '../../common/BotCache.js'; import { HighlightManager } from '../../common/HighlightManager.js'; -import { - ActivePunishment, - Global, - Guild as GuildModel, - GuildCount, - Highlight, - Level, - MemberCount, - ModLog, - Reminder, - Shared, - Stat, - StickyRole -} from '../../models/index.js'; import { AllowedMentions } from '../../utils/AllowedMentions.js'; import { BotClientUtils } from '../../utils/BotClientUtils.js'; import { emojis } from '../../utils/Constants.js'; @@ -279,7 +279,7 @@ export class TanzaniteClient<Ready extends boolean = boolean> extends AkairoClie const modify = async ( message: Message, - text: string | MessagePayload | MessageOptions | OtherwiseContentSupplier, + text: string | MessagePayload | MessageCreateOptions | OtherwiseContentSupplier, data: ArgumentPromptData, replaceError: boolean ) => { @@ -387,7 +387,7 @@ export class TanzaniteClient<Ready extends boolean = boolean> extends AkairoClie */ public async init() { if (parseInt(process.versions.node.split('.')[0]) < 18) { - void (await this.console.error('version', `Please use node <<v18.x.x>>, not <<${process.version}>>.`, false)); + void (await this.console.error('version', `Please use node <<v18.x.x>> or greater, not <<${process.version}>>.`, false)); process.exit(2); } diff --git a/lib/extensions/discord.js/BotClientEvents.ts b/lib/extensions/discord.js/BotClientEvents.ts index 941a6d8..88f67e9 100644 --- a/lib/extensions/discord.js/BotClientEvents.ts +++ b/lib/extensions/discord.js/BotClientEvents.ts @@ -1,4 +1,4 @@ -import type { AkairoClientEvents } from 'discord-akairo'; +import type { AkairoClientEvents } from '@notenoughupdates/discord-akairo'; import type { ButtonInteraction, Collection, diff --git a/lib/extensions/discord.js/ExtendedGuild.ts b/lib/extensions/discord.js/ExtendedGuild.ts index 6bf81ee..84db7d0 100644 --- a/lib/extensions/discord.js/ExtendedGuild.ts +++ b/lib/extensions/discord.js/ExtendedGuild.ts @@ -1,4 +1,5 @@ import { + Action, createModLogEntry, createModLogEntrySimple, createPunishmentEntry, @@ -17,15 +18,16 @@ import { Guild, JSONEncodable, Message, + MessageCreateOptions, MessageType, PermissionFlagsBits, SnowflakeUtil, ThreadChannel, + WebhookCreateMessageOptions, type APIMessage, type GuildMember, type GuildMemberResolvable, type GuildTextBasedChannel, - type MessageOptions, type MessagePayload, type NewsChannel, type Snowflake, @@ -33,10 +35,9 @@ import { type User, type UserResolvable, type VoiceChannel, - type Webhook, - type WebhookMessageOptions + type Webhook } from 'discord.js'; -import _ from 'lodash'; +import { camelCase } from 'lodash-es'; import { TanzaniteClient } from '../discord-akairo/TanzaniteClient.js'; import { banResponse, BanResponse, dmResponse, permissionsResponse, punishmentEntryRemove } from './ExtendedGuildMember.js'; @@ -91,7 +92,10 @@ interface Extension { * @param logType The corresponding channel that the message will be sent to * @param message The parameters for {@link TextChannel.send} */ - sendLogChannel(logType: GuildLogType, message: string | MessagePayload | MessageOptions): Promise<Message | null | undefined>; + sendLogChannel( + logType: GuildLogType, + message: string | MessagePayload | MessageCreateOptions + ): Promise<Message | null | undefined>; /** * Sends a formatted error message in a guild's error log channel * @param title The title of the error embed @@ -135,7 +139,7 @@ interface Extension { declare module 'discord.js' { export interface BaseGuild { - client: TanzaniteClient; + client: TanzaniteClient<true>; } export interface Guild extends AnonymousGuild, Extension {} @@ -202,7 +206,7 @@ export class ExtendedGuild extends Guild implements Extension { public override async sendLogChannel( logType: GuildLogType, - message: string | MessagePayload | MessageOptions + message: string | MessagePayload | MessageCreateOptions ): Promise<Message | null | undefined> { const logChannel = await this.getLogChannel(logType); if (!logChannel || !logChannel.isTextBased()) { @@ -220,7 +224,7 @@ export class ExtendedGuild extends Guild implements Extension { } public override async error(title: string, message: string): Promise<void> { - void this.client.console.info(_.camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); + void this.client.console.info(camelCase(title), message.replace(/\*\*(.*?)\*\*/g, '<<$1>>')); void this.sendLogChannel('error', { embeds: [{ title: title, description: message, color: colors.error }] }); } @@ -240,7 +244,7 @@ export class ExtendedGuild extends Guild implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, + type: options.duration ? ModLogType.TempBan : ModLogType.PermBan, user: user, moderator: moderator.id, reason: options.reason, @@ -257,7 +261,7 @@ export class ExtendedGuild extends Guild implements Extension { modlog: modlog.id, guild: this, user: user, - punishment: 'banned', + punishment: Action.Ban, duration: options.duration ?? 0, reason: options.reason ?? undefined, sendFooter: true @@ -309,7 +313,7 @@ export class ExtendedGuild extends Guild implements Extension { // add modlog entry const { log: modlog } = await createModLogEntrySimple({ client: this.client, - type: ModLogType.PERM_BAN, + type: ModLogType.PermBan, user: options.user, moderator: options.moderator, reason: options.reason, @@ -326,7 +330,7 @@ export class ExtendedGuild extends Guild implements Extension { modlog: modlog.id, guild: this, user: options.user, - punishment: 'banned', + punishment: Action.Ban, duration: 0, reason: options.reason ?? undefined, sendFooter: true @@ -390,7 +394,7 @@ export class ExtendedGuild extends Guild implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.UNBAN, + type: ModLogType.Unban, user: user.id, moderator: moderator.id, reason: options.reason, @@ -414,7 +418,7 @@ export class ExtendedGuild extends Guild implements Extension { client: this.client, guild: this, user: user, - punishment: 'unbanned', + punishment: Action.Unban, reason: options.reason ?? undefined, sendFooter: false }); @@ -546,7 +550,7 @@ export class ExtendedGuild extends Guild implements Extension { if (!webhook) return null; - const sendOptions: Omit<WebhookMessageOptions, 'flags'> = {}; + const sendOptions: Omit<WebhookCreateMessageOptions, 'flags'> = {}; const displayName = quote.member?.displayName ?? quote.author.username; @@ -559,7 +563,7 @@ export class ExtendedGuild extends Guild implements Extension { sendOptions.content = quote.content || undefined; sendOptions.threadId = channel instanceof ThreadChannel ? channel.id : undefined; sendOptions.embeds = quote.embeds.length ? quote.embeds : undefined; - //@ts-expect-error: jank + // @ts-expect-error: jank sendOptions.attachments = quote.attachments.size ? [...quote.attachments.values()].map((a) => AttachmentBuilder.from(a as JSONEncodable<AttachmentPayload>)) : undefined; @@ -720,7 +724,9 @@ export class ExtendedGuild extends Guild implements Extension { } sendOptions.allowedMentions = AllowedMentions.none(); - sendOptions.username ??= quote.member?.displayName ?? quote.author.username; + sendOptions.username ??= (quote.member?.displayName ?? quote.author.username) + .replaceAll(/discord/gi, '[REDACTED]') + .replaceAll(/clyde/gi, '[REDACTED]'); sendOptions.avatarURL = quote.member?.displayAvatarURL({ size: 2048 }) ?? quote.author.displayAvatarURL({ size: 2048 }); return await webhook.send(sendOptions); /* .catch((e: any) => e); */ diff --git a/lib/extensions/discord.js/ExtendedGuildMember.ts b/lib/extensions/discord.js/ExtendedGuildMember.ts index 9ef45f1..b11e9e3 100644 --- a/lib/extensions/discord.js/ExtendedGuildMember.ts +++ b/lib/extensions/discord.js/ExtendedGuildMember.ts @@ -8,11 +8,11 @@ import { type Role } from 'discord.js'; import { + Action, checkMutePermissions, createModLogEntry, createPunishmentEntry, punishDM, - PunishmentTypeDM, removePunishmentEntry } from '../../common/Moderation.js'; import { ModLogType } from '../../models/index.js'; @@ -32,7 +32,7 @@ interface Extension { * @returns Whether or not the dm was sent successfully. */ customPunishDM( - punishment: PunishmentTypeDM, + punishment: Action, reason?: string | null, duration?: number, modlog?: string, @@ -119,13 +119,13 @@ interface Extension { declare module 'discord.js' { export interface GuildMember extends Extension { - readonly client: TanzaniteClient; + readonly client: TanzaniteClient<true>; } } export class ExtendedGuildMember extends GuildMember implements Extension { public override async customPunishDM( - punishment: PunishmentTypeDM, + punishment: Action, reason?: string | null, duration?: number, modlog?: string, @@ -154,7 +154,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { const result = await createModLogEntry( { client: this.client, - type: ModLogType.WARN, + type: ModLogType.Warn, user: this, moderator: moderator.id, reason: options.reason, @@ -169,7 +169,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (!options.silent) { // dm user - const dmSuccess = await this.customPunishDM('warned', options.reason); + const dmSuccess = await this.customPunishDM(Action.Warn, options.reason); dmSuccessEvent = dmSuccess; if (!dmSuccess) return { result: warnResponse.DM_ERROR, caseNum: result.caseNum }; } @@ -195,7 +195,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (options.addToModlog || options.duration) { const { log: modlog } = await createModLogEntry({ client: this.client, - type: options.duration ? ModLogType.TEMP_PUNISHMENT_ROLE : ModLogType.PERM_PUNISHMENT_ROLE, + type: options.duration ? ModLogType.TempPunishmentRole : ModLogType.PermPunishmentRole, guild: this.guild, moderator: moderator.id, user: this, @@ -262,7 +262,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (options.addToModlog) { const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.REMOVE_PUNISHMENT_ROLE, + type: ModLogType.RemovePunishmentRole, guild: this.guild, moderator: moderator.id, user: this, @@ -362,7 +362,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: options.duration ? ModLogType.TEMP_MUTE : ModLogType.PERM_MUTE, + type: options.duration ? ModLogType.TempMute : ModLogType.PermMute, user: this, moderator: moderator.id, reason: options.reason, @@ -389,7 +389,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (!options.silent) { // dm user - const dmSuccess = await this.customPunishDM('muted', options.reason, options.duration ?? 0, modlog.id); + const dmSuccess = await this.customPunishDM(Action.Mute, options.reason, options.duration ?? 0, modlog.id); dmSuccessEvent = dmSuccess; if (!dmSuccess) return muteResponse.DM_ERROR; } @@ -441,7 +441,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.UNMUTE, + type: ModLogType.Unmute, user: this, moderator: moderator.id, reason: options.reason, @@ -465,7 +465,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (!options.silent) { // dm user - const dmSuccess = await this.customPunishDM('unmuted', options.reason, undefined, '', false); + const dmSuccess = await this.customPunishDM(Action.Unmute, options.reason, undefined, '', false); dmSuccessEvent = dmSuccess; if (!dmSuccess) return unmuteResponse.DM_ERROR; } @@ -505,7 +505,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.KICK, + type: ModLogType.Kick, user: this, moderator: moderator.id, reason: options.reason, @@ -517,7 +517,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { caseID = modlog.id; // dm user - const dmSuccess = options.silent ? null : await this.customPunishDM('kicked', options.reason, undefined, modlog.id); + const dmSuccess = options.silent ? null : await this.customPunishDM(Action.Kick, options.reason, undefined, modlog.id); dmSuccessEvent = dmSuccess ?? undefined; // kick @@ -564,7 +564,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: options.duration ? ModLogType.TEMP_BAN : ModLogType.PERM_BAN, + type: options.duration ? ModLogType.TempBan : ModLogType.PermBan, user: this, moderator: moderator.id, reason: options.reason, @@ -579,7 +579,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // dm user const dmSuccess = options.silent ? null - : await this.customPunishDM('banned', options.reason, options.duration ?? 0, modlog.id); + : await this.customPunishDM(Action.Ban, options.reason, options.duration ?? 0, modlog.id); dmSuccessEvent = dmSuccess ?? undefined; // ban @@ -646,7 +646,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: options.duration ? ModLogType.TEMP_CHANNEL_BLOCK : ModLogType.PERM_CHANNEL_BLOCK, + type: options.duration ? ModLogType.TempChannelBlock : ModLogType.PermChannelBlock, user: this, moderator: moderator.id, reason: options.reason, @@ -674,7 +674,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { ? null : await punishDM({ client: this.client, - punishment: 'blocked', + punishment: Action.Block, reason: options.reason ?? undefined, duration: options.duration ?? 0, modlog: modlog.id, @@ -736,7 +736,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.CHANNEL_UNBLOCK, + type: ModLogType.ChannelUnblock, user: this, moderator: moderator.id, reason: options.reason, @@ -762,7 +762,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { ? null : await punishDM({ client: this.client, - punishment: 'unblocked', + punishment: Action.Unblock, reason: options.reason ?? undefined, guild: this.guild, user: this, @@ -819,7 +819,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.TIMEOUT, + type: ModLogType.Timeout, user: this, moderator: moderator.id, reason: options.reason, @@ -834,7 +834,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (!options.silent) { // dm user - const dmSuccess = await this.customPunishDM('timedout', options.reason, options.duration, modlog.id); + const dmSuccess = await this.customPunishDM(Action.Timeout, options.reason, options.duration, modlog.id); dmSuccessEvent = dmSuccess; if (!dmSuccess) return timeoutResponse.DM_ERROR; } @@ -877,7 +877,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { // add modlog entry const { log: modlog } = await createModLogEntry({ client: this.client, - type: ModLogType.REMOVE_TIMEOUT, + type: ModLogType.RemoveTimeout, user: this, moderator: moderator.id, reason: options.reason, @@ -891,7 +891,7 @@ export class ExtendedGuildMember extends GuildMember implements Extension { if (!options.silent) { // dm user - const dmSuccess = await this.customPunishDM('untimedout', options.reason, undefined, '', false); + const dmSuccess = await this.customPunishDM(Action.Untimeout, options.reason, undefined, '', false); dmSuccessEvent = dmSuccess; if (!dmSuccess) return removeTimeoutResponse.DM_ERROR; } diff --git a/lib/extensions/discord.js/ExtendedMessage.ts b/lib/extensions/discord.js/ExtendedMessage.ts index 1bb0904..07cba3d 100644 --- a/lib/extensions/discord.js/ExtendedMessage.ts +++ b/lib/extensions/discord.js/ExtendedMessage.ts @@ -1,11 +1,11 @@ -import { CommandUtil } from 'discord-akairo'; +import { CommandUtil } from '@notenoughupdates/discord-akairo'; import { Message, type Client } from 'discord.js'; import type { RawMessageData } from 'discord.js/typings/rawDataTypes.js'; -export class ExtendedMessage<Cached extends boolean = boolean> extends Message<Cached> { - public declare util: CommandUtil<Message>; +export class ExtendedMessage<InGuild extends boolean = boolean> extends Message<InGuild> { + public declare util: CommandUtil<Message<InGuild>>; - public constructor(client: Client, data: RawMessageData) { + public constructor(client: Client<true>, data: RawMessageData) { super(client, data); this.util = new CommandUtil(client.commandHandler, this); } diff --git a/lib/extensions/discord.js/ExtendedUser.ts b/lib/extensions/discord.js/ExtendedUser.ts index 7846a70..8f9d27b 100644 --- a/lib/extensions/discord.js/ExtendedUser.ts +++ b/lib/extensions/discord.js/ExtendedUser.ts @@ -14,7 +14,7 @@ interface Extension { declare module 'discord.js' { export interface User extends Extension { - readonly client: TanzaniteClient; + readonly client: TanzaniteClient<true>; } } diff --git a/lib/global.ts b/lib/global.ts index 0a0bcca..d3419c7 100644 --- a/lib/global.ts +++ b/lib/global.ts @@ -1,5 +1,7 @@ /* eslint-disable */ +declare const nodeFetch: typeof import('node-fetch').default; + declare global { interface ReadonlyArray<T> { includes<S, R extends `${Extract<S, string>}`>( @@ -8,6 +10,8 @@ declare global { fromIndex?: number ): searchElement is R & S; } + + var fetch: typeof nodeFetch; } export {}; diff --git a/lib/index.ts b/lib/index.ts index fc7bb4c..d486d68 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -3,6 +3,7 @@ export * from './automod/AutomodShared.js'; export * from './automod/MemberAutomod.js'; export * from './automod/MessageAutomod.js'; export * from './automod/PresenceAutomod.js'; +export * from './common/Appeals.js'; export * from './common/BotCache.js'; export * from './common/ButtonPaginator.js'; export * from './common/CanvasProgressBar.js'; @@ -10,12 +11,9 @@ export * from './common/ConfirmationPrompt.js'; export * from './common/DeleteButton.js'; export * as Moderation from './common/Moderation.js'; export type { - AppealButtonId, CreateModLogEntryOptions, CreatePunishmentEntryOptions, PunishDMOptions, - PunishmentTypeDM, - PunishmentTypePresent, RemovePunishmentEntryOptions, SimpleCreateModLogEntryOptions } from './common/Moderation.js'; diff --git a/lib/models/instance/ActivePunishment.ts b/lib/models/instance/ActivePunishment.ts index 9bd9d01..1b57f47 100644 --- a/lib/models/instance/ActivePunishment.ts +++ b/lib/models/instance/ActivePunishment.ts @@ -4,10 +4,10 @@ import { DataTypes, type Sequelize } from 'sequelize'; import { BaseModel } from '../BaseModel.js'; export enum ActivePunishmentType { - BAN = 'BAN', - MUTE = 'MUTE', - ROLE = 'ROLE', - BLOCK = 'BLOCK' + Ban = 'BAN', + Mute = 'MUTE', + Role = 'ROLE', + Block = 'BLOCK' } export interface ActivePunishmentModel { diff --git a/lib/models/instance/Guild.ts b/lib/models/instance/Guild.ts index 72091ca..462beee 100644 --- a/lib/models/instance/Guild.ts +++ b/lib/models/instance/Guild.ts @@ -210,7 +210,7 @@ export const guildSettingsObj = asGuildSetting({ name: 'Auto Publish Channels', description: 'Channels were every message is automatically published.', type: 'channel-array', - subType: [ChannelType.GuildNews] + subType: [ChannelType.GuildAnnouncement] }, welcomeChannel: { name: 'Welcome Channel', @@ -218,10 +218,10 @@ export const guildSettingsObj = asGuildSetting({ type: 'channel', subType: [ ChannelType.GuildText, - ChannelType.GuildNews, - ChannelType.GuildNewsThread, - ChannelType.GuildPublicThread, - ChannelType.GuildPrivateThread + ChannelType.GuildAnnouncement, + ChannelType.AnnouncementThread, + ChannelType.PublicThread, + ChannelType.PrivateThread ] }, muteRole: { diff --git a/lib/models/instance/Level.ts b/lib/models/instance/Level.ts index e22d63b..27def46 100644 --- a/lib/models/instance/Level.ts +++ b/lib/models/instance/Level.ts @@ -18,6 +18,9 @@ export interface LevelModelCreationAttributes { * Leveling information for a user in a guild. */ export class Level extends BaseModel<LevelModel, LevelModelCreationAttributes> implements LevelModel { + public static MAX_XP = 2147483647; + public static MAX_LEVEL = Level.convertXpToLevel(Level.MAX_XP); + /** * The user's id. */ diff --git a/lib/models/instance/ModLog.ts b/lib/models/instance/ModLog.ts index 324ad83..7a1a60a 100644 --- a/lib/models/instance/ModLog.ts +++ b/lib/models/instance/ModLog.ts @@ -4,104 +4,98 @@ import { DataTypes, type Sequelize } from 'sequelize'; import { BaseModel } from '../BaseModel.js'; export enum ModLogType { - PERM_BAN = 'PERM_BAN', - TEMP_BAN = 'TEMP_BAN', - UNBAN = 'UNBAN', - KICK = 'KICK', - PERM_MUTE = 'PERM_MUTE', - TEMP_MUTE = 'TEMP_MUTE', - UNMUTE = 'UNMUTE', - WARN = 'WARN', - PERM_PUNISHMENT_ROLE = 'PERM_PUNISHMENT_ROLE', - TEMP_PUNISHMENT_ROLE = 'TEMP_PUNISHMENT_ROLE', - REMOVE_PUNISHMENT_ROLE = 'REMOVE_PUNISHMENT_ROLE', - PERM_CHANNEL_BLOCK = 'PERM_CHANNEL_BLOCK', - TEMP_CHANNEL_BLOCK = 'TEMP_CHANNEL_BLOCK', - CHANNEL_UNBLOCK = 'CHANNEL_UNBLOCK', - TIMEOUT = 'TIMEOUT', - REMOVE_TIMEOUT = 'REMOVE_TIMEOUT' + PermBan = 'PERM_BAN', + TempBan = 'TEMP_BAN', + Unban = 'UNBAN', + Kick = 'KICK', + PermMute = 'PERM_MUTE', + TempMute = 'TEMP_MUTE', + Unmute = 'UNMUTE', + Warn = 'WARN', + PermPunishmentRole = 'PERM_PUNISHMENT_ROLE', + TempPunishmentRole = 'TEMP_PUNISHMENT_ROLE', + RemovePunishmentRole = 'REMOVE_PUNISHMENT_ROLE', + PermChannelBlock = 'PERM_CHANNEL_BLOCK', + TempChannelBlock = 'TEMP_CHANNEL_BLOCK', + ChannelUnblock = 'CHANNEL_UNBLOCK', + Timeout = 'TIMEOUT', + RemoveTimeout = 'REMOVE_TIMEOUT' } -export interface ModLogModel { - id: string; - type: ModLogType; - user: Snowflake; - moderator: Snowflake; - reason: string | null; - duration: number | null; - guild: Snowflake; - evidence: string; - pseudo: boolean; - hidden: boolean; -} - -export interface ModLogModelCreationAttributes { - id?: string; - type: ModLogType; - user: Snowflake; - moderator: Snowflake; - reason?: string | null; - duration?: number; - guild: Snowflake; - evidence?: string; - pseudo?: boolean; - hidden?: boolean; +export enum AppealStatus { + None = 'NONE', + Submitted = 'SUBMITTED', + Accepted = 'ACCEPTED', + Denied = 'DENIED' } -/** - * A mod log case. - */ -export class ModLog extends BaseModel<ModLogModel, ModLogModelCreationAttributes> implements ModLogModel { +export interface ModLogModel { /** * The primary key of the modlog entry. */ - public declare id: string; - + id: string; /** * The type of punishment. */ - public declare type: ModLogType; - + type: ModLogType; /** * The user being punished. */ - public declare user: Snowflake; - + user: Snowflake; /** * The user carrying out the punishment. */ - public declare moderator: Snowflake; - + moderator: Snowflake; /** * The reason the user is getting punished. */ - public declare reason: string | null; - + reason: string | null; /** * The amount of time the user is getting punished for. */ - public declare duration: number | null; - + duration: number | null; /** * The guild the user is getting punished in. */ - public declare guild: Snowflake; - + guild: Snowflake; /** * Evidence of what the user is getting punished for. */ - public declare evidence: string; - + evidence: string; /** * Not an actual modlog just used so a punishment entry can be made. */ - public declare pseudo: boolean; - + pseudo: boolean; /** * Hides from the modlog command unless show hidden is specified. */ - public declare hidden: boolean; + hidden: boolean; + /** + * The status of an appeal for this punishment + */ + appeal: AppealStatus; +} +export interface ModLogModelCreationAttributes { + id?: string; + type: ModLogType; + user: Snowflake; + moderator: Snowflake; + reason?: string | null; + duration?: number; + guild: Snowflake; + evidence?: string; + pseudo?: boolean; + hidden?: boolean; + appeal?: AppealStatus; +} + +export interface ModLog extends ModLogModel {} + +/** + * A mod log case. + */ +export class ModLog extends BaseModel<ModLogModel, ModLogModelCreationAttributes> { /** * Initializes the model. * @param sequelize The sequelize instance. @@ -118,7 +112,8 @@ export class ModLog extends BaseModel<ModLogModel, ModLogModelCreationAttributes guild: { type: DataTypes.STRING, references: { model: 'Guilds', key: 'id' } }, evidence: { type: DataTypes.TEXT, allowNull: true }, pseudo: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, - hidden: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } + hidden: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + appeal: { type: DataTypes.STRING, allowNull: false, defaultValue: AppealStatus.None } }, { sequelize } ); diff --git a/lib/types/misc.ts b/lib/types/misc.ts index 5bf760c..5f28dae 100644 --- a/lib/types/misc.ts +++ b/lib/types/misc.ts @@ -1,14 +1,14 @@ import type { InteractionReplyOptions, + MessageCreateOptions, MessageEditOptions, - MessageOptions, MessagePayload, - ReplyMessageOptions, + MessageReplyOptions, WebhookEditMessageOptions } from 'discord.js'; -export type ReplyMessageType = string | MessagePayload | ReplyMessageOptions; +export type ReplyMessageType = string | MessagePayload | MessageReplyOptions; export type EditMessageType = string | MessageEditOptions | MessagePayload; export type SlashSendMessageType = string | MessagePayload | InteractionReplyOptions; export type SlashEditMessageType = string | MessagePayload | WebhookEditMessageOptions; -export type SendMessageType = string | MessagePayload | MessageOptions; +export type SendMessageType = string | MessagePayload | MessageCreateOptions; diff --git a/lib/utils/Arg.ts b/lib/utils/Arg.ts index 80ca878..803230b 100644 --- a/lib/utils/Arg.ts +++ b/lib/utils/Arg.ts @@ -1,5 +1,5 @@ import type { BaseBotArgumentType, BotArgumentType, BotArgumentTypeCaster, CommandMessage, SlashMessage } from '#lib'; -import { Argument, type Command, type Flag, type ParsedValuePredicate } from 'discord-akairo'; +import { Argument, type Command, type Flag, type ParsedValuePredicate } from '@notenoughupdates/discord-akairo'; import { type Message } from 'discord.js'; /** diff --git a/lib/utils/BotClientUtils.ts b/lib/utils/BotClientUtils.ts index 4b2c99b..6837237 100644 --- a/lib/utils/BotClientUtils.ts +++ b/lib/utils/BotClientUtils.ts @@ -21,7 +21,7 @@ import { type Snowflake, type UserResolvable } from 'discord.js'; -import _ from 'lodash'; +import { camelCase } from 'lodash-es'; import { emojis, Pronoun, PronounCode, pronounMapping, regex } from './Constants.js'; import { generateErrorEmbed } from './ErrorHandler.js'; import { addOrRemoveFromArray, formatError, inspect } from './Utils.js'; @@ -329,7 +329,7 @@ export class BotClientUtils { * @param error */ public async handleError(context: string, error: Error) { - await this.client.console.error(_.camelCase(context), `An error occurred:\n${formatError(error, false)}`, false); + await this.client.console.error(camelCase(context), `An error occurred:\n${formatError(error, false)}`, false); await this.client.console.channelError({ embeds: await generateErrorEmbed(this.client, { type: 'unhandledRejection', error: error, context }) }); @@ -382,6 +382,7 @@ export class BotClientUtils { public async uploadImageToImgur(image: string) { const clientId = this.client.config.credentials.imgurClientId; + // @ts-expect-error: missing global types const formData = new FormData(); formData.append('type', 'base64'); formData.append('image', image); diff --git a/lib/utils/Constants.ts b/lib/utils/Constants.ts index dd65e28..5ecbce2 100644 --- a/lib/utils/Constants.ts +++ b/lib/utils/Constants.ts @@ -1,4 +1,4 @@ -import { default as deepLock } from 'deep-lock'; +import deepLock from '@tanzanite/deep-lock'; import { Colors, GuildFeature, Snowflake } from 'discord.js'; const rawCapeUrl = 'https://raw.githubusercontent.com/NotEnoughUpdates/capes/master/'; @@ -294,6 +294,7 @@ export const mappings = deepLock({ AUTO_MODERATION: { name: 'Auto Moderation', important: false, emoji: '<:autoModeration:1010579417942200321>', weight: 33 }, MEMBER_PROFILES: { name: 'Member Profiles', important: false, emoji: '<:memberProfiles:1010580480409747547>', weight: 34 }, NEW_THREAD_PERMISSIONS: { name: 'New Thread Permissions', important: false, emoji: '<:newThreadPermissions:1010580968442171492>', weight: 35 }, + [GuildFeature.InvitesDisabled]: { name: 'Invites Disabled', important: false, emoji: null, weight: 36 }, }, regions: { diff --git a/lib/utils/ErrorHandler.ts b/lib/utils/ErrorHandler.ts index 3f8be89..ea4a026 100644 --- a/lib/utils/ErrorHandler.ts +++ b/lib/utils/ErrorHandler.ts @@ -1,4 +1,4 @@ -import { AkairoMessage, Command } from 'discord-akairo'; +import { AkairoMessage, Command } from '@notenoughupdates/discord-akairo'; import { ChannelType, Client, EmbedBuilder, escapeInlineCode, GuildTextBasedChannel, Message } from 'discord.js'; import { BotCommandHandlerEvents } from '../extensions/discord-akairo/BotCommandHandler.js'; import { SlashMessage } from '../extensions/discord-akairo/SlashMessage.js'; diff --git a/lib/utils/Utils.ts b/lib/utils/Utils.ts index 13806ec..ea70abf 100644 --- a/lib/utils/Utils.ts +++ b/lib/utils/Utils.ts @@ -1,9 +1,11 @@ +import { Util as AkairoUtil } from '@notenoughupdates/discord-akairo'; import { humanizeDuration as humanizeDurationMod } from '@notenoughupdates/humanize-duration'; +import deepLock from '@tanzanite/deep-lock'; import assert from 'assert/strict'; import cp from 'child_process'; -import deepLock from 'deep-lock'; -import { Util as AkairoUtil } from 'discord-akairo'; import { + ActionRowBuilder, + APITextInputComponent, Constants as DiscordConstants, EmbedBuilder, Message, @@ -11,6 +13,8 @@ import { PermissionFlagsBits, PermissionsBitField, PermissionsString, + TextInputBuilder, + TextInputComponentData, type APIEmbed, type APIMessage, type CommandInteraction, @@ -159,7 +163,7 @@ export async function slashRespond( delete (newResponseOptions as InteractionReplyOptions).ephemeral; // Cannot change a preexisting message to be ephemeral return (await interaction.editReply(newResponseOptions)) as Message | APIMessage; } else { - await interaction.reply(newResponseOptions); + await interaction.reply(newResponseOptions as SlashSendMessageType); return await interaction.fetchReply().catch(() => undefined); } } @@ -547,3 +551,9 @@ export function deepWriteable<T>(obj: T): DeepWritable<T> { export function formatPerms(permissions: PermissionsString[]) { return permissions.map((p) => `\`${mappings.permissions[p]?.name ?? p}\``).join(', '); } + +export function ModalInput(options: Partial<TextInputComponentData | APITextInputComponent>): ActionRowBuilder<TextInputBuilder> { + return new ActionRowBuilder<TextInputBuilder>({ + components: [new TextInputBuilder(options)] + }); +} |