From 612ed820a0600ec11ed642005377cd7f5a8a8b77 Mon Sep 17 00:00:00 2001 From: IRONM00N <64110067+IRONM00N@users.noreply.github.com> Date: Mon, 3 Oct 2022 22:57:40 -0400 Subject: wip --- .yarnrc.yml | 2 +- lib/automod/AutomodShared.ts | 4 +- lib/common/Appeals.ts | 273 ++++ lib/common/ButtonPaginator.ts | 6 +- lib/common/ConfirmationPrompt.ts | 12 +- lib/common/DeleteButton.ts | 8 +- lib/common/HighlightManager.ts | 3 + lib/common/Moderation.ts | 668 +++++---- lib/common/tags.ts | 132 +- lib/extensions/discord-akairo/BotCommand.ts | 33 +- lib/extensions/discord-akairo/BotCommandHandler.ts | 11 +- lib/extensions/discord-akairo/BotInhibitor.ts | 6 +- .../discord-akairo/BotInhibitorHandler.ts | 7 +- lib/extensions/discord-akairo/BotListener.ts | 5 +- .../discord-akairo/BotListenerHandler.ts | 2 +- lib/extensions/discord-akairo/BotTask.ts | 2 +- lib/extensions/discord-akairo/BotTaskHandler.ts | 2 +- lib/extensions/discord-akairo/SlashMessage.ts | 2 +- lib/extensions/discord-akairo/TanzaniteClient.ts | 48 +- lib/extensions/discord.js/BotClientEvents.ts | 2 +- lib/extensions/discord.js/ExtendedGuild.ts | 40 +- lib/extensions/discord.js/ExtendedGuildMember.ts | 48 +- lib/extensions/discord.js/ExtendedMessage.ts | 8 +- lib/extensions/discord.js/ExtendedUser.ts | 2 +- lib/global.ts | 4 + lib/index.ts | 4 +- lib/models/instance/ActivePunishment.ts | 8 +- lib/models/instance/Guild.ts | 10 +- lib/models/instance/Level.ts | 3 + lib/models/instance/ModLog.ts | 123 +- lib/types/misc.ts | 8 +- lib/utils/Arg.ts | 2 +- lib/utils/BotClientUtils.ts | 5 +- lib/utils/Constants.ts | 3 +- lib/utils/ErrorHandler.ts | 2 +- lib/utils/Utils.ts | 16 +- neu-item-repo | 2 +- neu-item-repo-dangerous | 2 +- package.json | 77 +- src/bot.ts | 28 +- src/commands/config/config.ts | 17 +- src/commands/config/disable.ts | 4 +- src/commands/config/log.ts | 10 +- src/commands/dev/eval.ts | 2 +- src/commands/dev/superUser.ts | 2 +- src/commands/dev/test.ts | 156 +- src/commands/fun/minesweeper.ts | 4 +- src/commands/info/guildInfo.ts | 111 +- src/commands/info/help.ts | 4 +- src/commands/info/inviteInfo.ts | 7 +- src/commands/info/ping.ts | 2 +- src/commands/info/snowflake.ts | 48 +- src/commands/info/userInfo.ts | 112 +- src/commands/leveling/level.ts | 8 +- src/commands/leveling/setLevel.ts | 28 +- src/commands/leveling/setXp.ts | 34 +- src/commands/moderation/ban.ts | 4 +- src/commands/moderation/block.ts | 2 +- src/commands/moderation/evidence.ts | 2 +- src/commands/moderation/kick.ts | 2 +- src/commands/moderation/modlog.ts | 60 +- src/commands/moderation/mute.ts | 2 +- src/commands/moderation/myLogs.ts | 12 +- src/commands/moderation/role.ts | 2 +- src/commands/moderation/slowmode.ts | 6 +- src/commands/moderation/timeout.ts | 8 +- src/commands/moderation/unblock.ts | 8 +- src/commands/moderation/unmute.ts | 8 +- src/commands/moderation/untimeout.ts | 8 +- src/commands/moderation/warn.ts | 2 +- src/commands/moulberry-bush/capes.ts | 6 +- src/commands/tickets/ticket-!.ts | 2 +- src/commands/utilities/activity.ts | 2 +- src/commands/utilities/highlight-!.ts | 2 +- src/commands/utilities/highlight-block.ts | 2 +- src/commands/utilities/highlight-matches.ts | 2 +- src/commands/utilities/highlight-unblock.ts | 2 +- src/commands/utilities/price.ts | 4 +- src/commands/utilities/steal.ts | 8 +- src/commands/utilities/whoHasRole.ts | 2 +- src/commands/utilities/wolframAlpha.ts | 6 +- src/context-menu-commands/message/viewRaw.ts | 2 +- src/context-menu-commands/user/modlog.ts | 9 +- src/context-menu-commands/user/userInfo.ts | 28 +- src/listeners/bush/appealListener.ts | 14 +- src/listeners/bush/experimentYoink.ts | 28 + src/listeners/client/ready.ts | 28 +- src/listeners/commands/commandBlocked.ts | 4 +- .../contextCommands/contextCommandBlocked.ts | 4 +- .../contextCommands/contextCommandError.ts | 2 +- .../contextCommands/contextCommandNotFound.ts | 2 +- .../contextCommands/contextCommandStarted.ts | 2 +- src/listeners/guild/syncUnbanPunishmentModel.ts | 2 +- src/listeners/interaction/$interactionCreate.ts | 31 + src/listeners/interaction/button.ts | 129 ++ src/listeners/interaction/interactionCreate.ts | 78 - src/listeners/interaction/modalSubmit.ts | 20 + src/listeners/interaction/selectMenu.ts | 23 + src/listeners/member-custom/levelUpdate.ts | 3 +- src/listeners/message/autoPublisher.ts | 5 +- src/listeners/message/level.ts | 57 +- src/listeners/message/quoteCreate.ts | 2 +- .../track-manual-punishments/modlogSyncBan.ts | 2 +- .../track-manual-punishments/modlogSyncKick.ts | 2 +- .../track-manual-punishments/modlogSyncTimeout.ts | 2 +- .../track-manual-punishments/modlogSyncUnban.ts | 2 +- src/listeners/ws/INTERACTION_CREATE.ts | 236 +-- src/tasks/feature/removeExpiredPunishements.ts | 8 +- yarn.lock | 1582 +++++++------------- 109 files changed, 2410 insertions(+), 2233 deletions(-) create mode 100644 lib/common/Appeals.ts create mode 100644 src/listeners/bush/experimentYoink.ts create mode 100644 src/listeners/interaction/$interactionCreate.ts delete mode 100644 src/listeners/interaction/interactionCreate.ts create mode 100644 src/listeners/interaction/modalSubmit.ts create mode 100644 src/listeners/interaction/selectMenu.ts diff --git a/.yarnrc.yml b/.yarnrc.yml index bd5ccfd..33ad4a3 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,7 +2,7 @@ enableGlobalCache: true enableTelemetry: false -nodeLinker: pnpm +nodeLinker: node-modules plugins: - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 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().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().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().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 { + public static async send(message: CommandMessage | SlashMessage, sendOptions: MessageCreateOptions): Promise { 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) { + public static async send(message: CommandMessage | SlashMessage, options: Omit) { 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.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 { 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,17 +263,48 @@ export async function permissionCheck( export async function checkMutePermissions( guild: Guild ): Promise | ValueOf | 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. @@ -165,6 +330,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. @@ -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; @@ -205,6 +391,41 @@ export async function createModLogEntrySimple( return { log: saveResult, caseNum }; } +/** + * 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. @@ -221,12 +442,38 @@ 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; }); } +/** + * 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. @@ -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 { - 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({ - 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 { + 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({ + 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 = T extends any ? keyof T : never; + const newArg: { - [key in SlashOptionKeys]?: any; + [key in AllKeys]?: 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; public declare categories: Collection>; 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; + 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 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 extends AkairoClie */ public async init() { if (parseInt(process.versions.node.split('.')[0]) < 18) { - void (await this.console.error('version', `Please use node <>, not <<${process.version}>>.`, false)); + void (await this.console.error('version', `Please use node <> 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; + sendLogChannel( + logType: GuildLogType, + message: string | MessagePayload | MessageCreateOptions + ): Promise; /** * 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; } 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 { 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 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 = {}; + const sendOptions: Omit = {}; const displayName = quote.member?.displayName ?? quote.author.username; @@ -559,7 +563,7 @@ export class ExtendedGuild