diff --git a/bots/discord/package.json b/bots/discord/package.json index e525e7a..0378f09 100644 --- a/bots/discord/package.json +++ b/bots/discord/package.json @@ -6,12 +6,11 @@ "description": "🤖 Discord bot assisting ReVanced", "main": "src/index.ts", "scripts": { - "register": "bun run scripts/reload-slash-commands.ts", "start": "bun prepare && bun run src/index.ts", "dev": "bun prepare && bun --watch src/index.ts", "build": "bun prepare && bun run scripts/build.ts", "watch": "bun dev", - "prepare": "bun run scripts/generate-indexes.ts && bunx drizzle-kit generate --name=schema" + "prepare": "bun run scripts/generate-indexes.ts && bunx --bun drizzle-kit generate --name=schema" }, "repository": { "type": "git", diff --git a/bots/discord/src/classes/Command.ts b/bots/discord/src/classes/Command.ts index f02a573..0331d0f 100644 --- a/bots/discord/src/classes/Command.ts +++ b/bots/discord/src/classes/Command.ts @@ -1,76 +1,145 @@ -import { ApplicationCommandOptionType } from 'discord.js' +import { ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js' +import { isAdmin } from '../utils/discord/permissions' -import { createErrorEmbed } from '$/utils/discord/embeds' -import { isAdmin } from '$/utils/discord/permissions' - -import { config } from '../context' import CommandError, { CommandErrorType } from './CommandError' -import type { Filter } from 'config.schema' import type { APIApplicationCommandChannelOption, CacheType, Channel, ChatInputCommandInteraction, + CommandInteraction, CommandInteractionOption, GuildMember, Message, + MessageContextMenuCommandInteraction, + RESTPostAPIApplicationCommandsJSONBody, RESTPostAPIChatInputApplicationCommandsJSONBody, Role, User, + UserContextMenuCommandInteraction, + UserResolvable, } from 'discord.js' +import { config } from '../context' + +export enum CommandType { + ChatGlobal = 1, + ChatGuild, + ContextMenuUser, + ContextMenuMessage, + ContextMenuGuildMessage, + ContextMenuGuildMember, +} export default class Command< - Global extends boolean = false, - Options extends CommandOptionsOptions | undefined = undefined, - AllowMessageCommand extends boolean = false, + const Type extends CommandType = CommandType.ChatGuild, + const Options extends If, undefined, CommandOptionsOptions | undefined> = undefined, + const AllowMessageCommand extends If, false, boolean> = false, > { name: string description: string requirements?: CommandRequirements options?: Options - global?: Global - #execute: CommandExecuteFunction + type: Type + allowMessageCommand: AllowMessageCommand + #execute: CommandExecuteFunction static OptionType = ApplicationCommandOptionType + static Type = CommandType constructor({ name, description, requirements, options, - global, + type, + allowMessageCommand, execute, - }: CommandOptions) { + }: CommandOptions) { this.name = name - this.description = description + this.description = description! this.requirements = requirements this.options = options - this.global = global + // @ts-expect-error: Default is `CommandType.GuildOnly`, it makes sense + this.type = type ?? CommandType.ChatGuild + // @ts-expect-error: Default is `false`, it makes sense + this.allowMessageCommand = allowMessageCommand ?? false this.#execute = execute } + isGuildSpecific(): this is Command< + CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage, + Options, + AllowMessageCommand + > { + return [ + CommandType.ChatGuild, + CommandType.ContextMenuGuildMessage, + CommandType.ContextMenuGuildMember, + ].includes(this.type) + } + + isContextMenuSpecific(): this is Command< + | CommandType.ContextMenuGuildMessage + | CommandType.ContextMenuGuildMember + | CommandType.ContextMenuUser + | CommandType.ContextMenuMessage, + undefined, + false + > { + return [ + CommandType.ContextMenuMessage, + CommandType.ContextMenuUser, + CommandType.ContextMenuGuildMessage, + CommandType.ContextMenuGuildMember, + ].includes(this.type) + } + + isGuildContextMenuSpecific(): this is Command< + CommandType.ContextMenuGuildMessage | CommandType.ContextMenuGuildMember, + undefined, + false + > { + return [CommandType.ContextMenuGuildMessage, CommandType.ContextMenuGuildMember].includes(this.type) + } + + async onContextMenuInteraction( + context: typeof import('../context'), + interaction: If< + Extends, + MessageContextMenuCommandInteraction>, + UserContextMenuCommandInteraction> + >, + ): Promise { + if (!this.isGuildSpecific() && !interaction.inGuild()) + throw new CommandError(CommandErrorType.InteractionNotInGuild) + + const executor = await this.#fetchInteractionExecutor(interaction) + const target = + this.type === CommandType.ContextMenuGuildMember + ? this.isGuildSpecific() + ? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>, interaction.targetId) + : interaction.client.users.fetch(interaction.targetId) + : interaction.channel?.messages.fetch(interaction.targetId) + + if (!target) throw new CommandError(CommandErrorType.FetchManagerNotFound) + + // @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough + return await this.#execute({ ...context, executor, target }, interaction, undefined) + } + async onMessage( context: typeof import('../context'), - msg: Message>, + msg: Message>, args: CommandArguments, ): Promise { - if (!this.global && !msg.inGuild()) - return await msg.reply({ - embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')], - }) + if (!this.allowMessageCommand) return + if (!this.isGuildSpecific() && !msg.guildId) throw new CommandError(CommandErrorType.InteractionNotInGuild) - const executor = this.global ? msg.author : await msg.guild?.members.fetch(msg.author.id)! - - if (!(await this.canExecute(executor, msg.channelId))) - return await msg.reply({ - embeds: [ - createErrorEmbed( - 'Cannot run this command', - 'You do not meet the requirements to run this command.', - ), - ], - }) + const executor = this.isGuildSpecific() + ? await msg.guild?.members.fetch(msg.author)! + : await msg.client.users.fetch(msg.author) + if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet) const options = this.options ? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter< @@ -120,14 +189,18 @@ export default class Command< `Invalid type for argument **${name}**.${argExplainationString}\n\nExpected type: **${expectedType}**\nGot type: **${ApplicationCommandOptionType[arg.type]}**${choicesString}`, ) - if ('choices' in option && option.choices && !option.choices.some(({ value }) => value === arg)) + const argValue = typeof arg === 'string' ? arg : arg?.id + + if ( + 'choices' in option && + option.choices && + !option.choices.some(({ value }) => value === (typeof value === 'number' ? Number(argValue) : argValue)) + ) throw new CommandError( CommandErrorType.InvalidArgument, - `Invalid choice for argument **${name}**.\n${argExplainationString}\n\n${choicesString}\n`, + `Invalid choice for argument **${name}**.\n${argExplainationString}${choicesString}\n`, ) - const argValue = typeof arg === 'string' ? arg : arg?.id - if (argValue && arg) { if (isSubcommandLikeOption) { const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)! @@ -142,6 +215,16 @@ export default class Command< break } + if ( + type === ApplicationCommandOptionType.String && + ((typeof option.minLength === 'number' && argValue.length < option.minLength) || + (typeof option.maxLength === 'number' && argValue.length > option.maxLength)) + ) + throw new CommandError( + CommandErrorType.InvalidArgument, + `Invalid string length for argument **${name}**.\nLengths allowed: ${option.minLength ?? '(any)'} - ${option.maxLength ?? '(any)'}.${argExplainationString}`, + ) + if ( (type === ApplicationCommandOptionType.Channel || type === ApplicationCommandOptionType.User || @@ -153,14 +236,21 @@ export default class Command< `Malformed ID for argument **${name}**.${argExplainationString}`, ) - if ( - (type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) && - Number.isNaN(Number(argValue)) - ) { - throw new CommandError( - CommandErrorType.InvalidArgument, - `Invalid number for argument **${name}**.${argExplainationString}`, + if (type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) { + if (Number.isNaN(Number(argValue))) + throw new CommandError( + CommandErrorType.InvalidArgument, + `Invalid number for argument **${name}**.${argExplainationString}`, + ) + + if ( + (typeof option.min === 'number' && Number(argValue) < option.min) || + (typeof option.max === 'number' && Number(argValue) > option.max) ) + throw new CommandError( + CommandErrorType.InvalidArgument, + `Number out of range for argument **${name}**.\nRange allowed: ${option.min ?? '(any)'} - ${option.max ?? '(any)'}.${argExplainationString}`, + ) } if ( @@ -177,7 +267,7 @@ export default class Command< type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer ? Number(argValue) : type === ApplicationCommandOptionType.Boolean - ? argValue[0] === 't' || argValue[0] === 'y' + ? ['t', 'y', 'yes', 'true'].some(value => value === argValue.toLowerCase()) : type === ApplicationCommandOptionType.Channel ? await msg.client.channels.fetch(argValue) : type === ApplicationCommandOptionType.User @@ -191,44 +281,27 @@ export default class Command< return _options } + #fetchInteractionExecutor(interaction: CommandInteraction) { + return this.isGuildSpecific() + ? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>) + : fetchUser(interaction) + } + async onInteraction( context: typeof import('../context'), interaction: ChatInputCommandInteraction, ): Promise { - const { logger } = context + if (interaction.commandName !== this.name) + throw new CommandError( + CommandErrorType.InteractionDataMismatch, + 'The interaction command name does not match the expected command name.', + ) - if (interaction.commandName !== this.name) { - logger.warn(`Command name mismatch, expected ${this.name}, but got ${interaction.commandName}!`) - return await interaction.reply({ - embeds: [ - createErrorEmbed( - 'Internal command name mismatch', - 'The interaction command name does not match the expected command name.', - ), - ], - }) - } + if (!this.isGuildSpecific() && !interaction.inGuild()) + throw new CommandError(CommandErrorType.InteractionNotInGuild) - if (!this.global && !interaction.inGuild()) { - logger.error(`Command ${this.name} cannot be run in DMs, but was registered as global`) - return await interaction.reply({ - embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')], - ephemeral: true, - }) - } - - const executor = this.global ? interaction.user : await interaction.guild?.members.fetch(interaction.user.id)! - - if (!(await this.canExecute(executor, interaction.channelId))) - return await interaction.reply({ - embeds: [ - createErrorEmbed( - 'Cannot run this command', - 'You do not meet the requirements to run this command.', - ), - ], - ephemeral: true, - }) + const executor = await this.#fetchInteractionExecutor(interaction) + if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet) const options = this.options ? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter< @@ -237,14 +310,10 @@ export default class Command< : undefined if (options === null) - return await interaction.reply({ - embeds: [ - createErrorEmbed( - 'Internal command option type mismatch', - 'The interaction command option type does not match the expected command option type.', - ), - ], - }) + throw new CommandError( + CommandErrorType.InteractionDataMismatch, + 'The registered interaction command option type does not match the expected command option type.', + ) // @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough return await this.#execute({ ...context, executor }, interaction, options) @@ -288,7 +357,7 @@ export default class Command< return _options } - async canExecute(executor: User | GuildMember, channelId: string): Promise { + async canExecute(executor: User | GuildMember): Promise { if (!this.requirements) return false const isExecutorAdmin = isAdmin(executor) @@ -296,7 +365,6 @@ export default class Command< const { adminOnly, - channels, roles, permissions, users, @@ -305,16 +373,23 @@ export default class Command< memberRequirementsForUsers = 'pass', } = this.requirements - const member = this.global ? null : (executor as GuildMember) - const bDefCond = defaultCondition !== 'fail' - const bMemReqForUsers = memberRequirementsForUsers !== 'fail' + const member = this.isGuildSpecific() ? null : (executor as GuildMember) + const boolDefaultCondition = defaultCondition !== 'fail' + const boolMemberRequirementsForUsers = memberRequirementsForUsers !== 'fail' const conditions = [ - adminOnly ? isExecutorAdmin : bDefCond, - channels ? channels.includes(channelId) : bDefCond, - member ? (roles ? roles.some(role => member.roles.cache.has(role)) : bDefCond) : bMemReqForUsers, - member ? (permissions ? member.permissions.has(permissions) : bDefCond) : bMemReqForUsers, - users ? users.includes(executor.id) : bDefCond, + adminOnly ? isExecutorAdmin : boolDefaultCondition, + users ? users.includes(executor.id) : boolDefaultCondition, + member + ? roles + ? roles.some(role => member.roles.cache.has(role)) + : boolDefaultCondition + : boolMemberRequirementsForUsers, + member + ? permissions + ? member.permissions.has(permissions) + : boolDefaultCondition + : boolMemberRequirementsForUsers, ] if (mode === 'all' && conditions.some(condition => !condition)) return false @@ -323,14 +398,27 @@ export default class Command< return true } - get json(): RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } { - return { + get json(): RESTPostAPIApplicationCommandsJSONBody { + // @ts-expect-error: I hate union types in TypeScript + const base: RESTPostAPIApplicationCommandsJSONBody = { name: this.name, + type: + this.type === CommandType.ContextMenuGuildMessage || this.type === CommandType.ContextMenuMessage + ? ApplicationCommandType.Message + : this.type === CommandType.ContextMenuGuildMember || this.type === CommandType.ContextMenuUser + ? ApplicationCommandType.User + : ApplicationCommandType.ChatInput, + } + + if (this.isContextMenuSpecific()) return base + + return { + ...base, description: this.description, options: this.options ? this.#transformOptions(this.options) : undefined, // https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types - contexts: this.global ? [0] : [0, 1], - } + contexts: this.isGuildSpecific() ? [0] : [0, 1, 2], + } as RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } } #transformOptions(optionsObject: Record) { @@ -377,8 +465,8 @@ export default class Command< export class ModerationCommand< Options extends CommandOptionsOptions, AllowMessageCommand extends boolean = true, -> extends Command { - constructor(options: ExtendedCommandOptions) { +> extends Command { + constructor(options: ExtendedCommandOptions) { super({ ...options, requirements: { @@ -388,17 +476,16 @@ export class ModerationCommand< }, // @ts-expect-error: No thanks allowMessageCommand: options.allowMessageCommand ?? true, - global: false, + type: CommandType.ChatGuild, }) } } -export class AdminCommand extends Command< - true, - Options, - AllowMessageCommand -> { - constructor(options: ExtendedCommandOptions) { +export class AdminCommand< + Options extends CommandOptionsOptions, + AllowMessageCommand extends boolean = true, +> extends Command { + constructor(options: ExtendedCommandOptions) { super({ ...options, requirements: { @@ -406,38 +493,52 @@ export class AdminCommand, + source: UserResolvable = interaction.user, + manager = interaction.guild?.members, +) => { + const _manager = manager ?? (await interaction.client.guilds.fetch(interaction.guildId).then(it => it.members)) + if (!_manager) throw new CommandError(CommandErrorType.FetchManagerNotFound, 'Cannot fetch member.') + return await _manager.fetch(source) +} + +const fetchUser = (interaction: CommandInteraction, source: UserResolvable = interaction.user) => { + return interaction.client.users.fetch(source) +} + /* TODO: APIApplicationCommandAttachmentOption APIApplicationCommandMentionableOption APIApplicationCommandRoleOption */ -export interface CommandOptions< - Global extends boolean, +export type CommandOptions< + Type extends CommandType, Options extends CommandOptionsOptions | undefined, AllowMessageCommand extends boolean, -> { +> = { name: string - description: string requirements?: CommandRequirements options?: Options - execute: CommandExecuteFunction - global?: Global + execute: CommandExecuteFunction + type?: Type allowMessageCommand?: AllowMessageCommand -} +} & If, { description?: never }, { description: string }> export type CommandArguments = Array - export type CommandSpecialArgument = { type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType] id: string } +//! If things ever get minified, this will most likely break property access via string names export const CommandSpecialArgumentType = { Channel: ApplicationCommandOptionType.Channel, Role: ApplicationCommandOptionType.Role, @@ -445,31 +546,56 @@ export const CommandSpecialArgumentType = { } type ExtendedCommandOptions< - Global extends boolean, + Type extends CommandType, Options extends CommandOptionsOptions, AllowMessageCommand extends boolean, -> = Omit, 'global'> & { - requirements?: Omit['requirements'], 'defaultCondition'> +> = Omit, 'type'> & { + requirements?: Omit['requirements'], 'defaultCondition'> } export type CommandOptionsOptions = Record +type ToCacheType = If, 'raw' | 'cached', CacheType> + type CommandExecuteFunction< - Global extends boolean, + Type extends CommandType, Options extends CommandOptionsOptions | undefined, AllowMessageCommand extends boolean, > = ( - context: CommandContext, + context: CommandContext, trigger: If< AllowMessageCommand, - Message> | ChatInputCommandInteraction>, - ChatInputCommandInteraction> + Message> | CommandTypeToInteractionMap>[Type], + CommandTypeToInteractionMap>[Type] >, options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter : never, ) => Promise | unknown +type CommandTypeToInteractionMap = { + [CommandType.ChatGlobal]: ChatInputCommandInteraction + [CommandType.ChatGuild]: ChatInputCommandInteraction + [CommandType.ContextMenuUser]: UserContextMenuCommandInteraction + [CommandType.ContextMenuMessage]: MessageContextMenuCommandInteraction + [CommandType.ContextMenuGuildMessage]: MessageContextMenuCommandInteraction + [CommandType.ContextMenuGuildMember]: MessageContextMenuCommandInteraction +} + +type IsContextMenu = Extends< + Type, + | CommandType.ContextMenuGuildMessage + | CommandType.ContextMenuGuildMember + | CommandType.ContextMenuMessage + | CommandType.ContextMenuUser +> + +type IsGuildSpecific = Extends< + Type, + CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage +> + +type Extends = T extends U ? true : false type If = T extends true ? U : V -type InvertBoolean = If +// type InvertBoolean = If type CommandExecuteFunctionOptionsParameter = { [K in keyof Options]: Options[K]['type'] extends @@ -484,8 +610,13 @@ type CommandExecuteFunctionOptionsParameter } -type CommandContext = typeof import('../context') & { - executor: CommandExecutor +type CommandContext = typeof import('../context') & { + executor: CommandExecutor + target: If< + Extends, + GuildMember, + If, Message, never> + > } type CommandOptionValueMap = { @@ -511,7 +642,7 @@ type CommandOption = | CommandSubcommandOption | CommandSubcommandGroupOption -type CommandExecutor = If +type CommandExecutor = If, GuildMember, User> type CommandOptionBase = { type: Type @@ -585,10 +716,12 @@ interface CommandSubcommandLikeOption< type CommandSubcommandOption = CommandSubcommandLikeOption type CommandSubcommandGroupOption = CommandSubcommandLikeOption -export type CommandRequirements = Filter & { - mode?: 'all' | 'any' - adminOnly?: boolean +export type CommandRequirements = { + users?: string[] + roles?: string[] permissions?: bigint + adminOnly?: boolean defaultCondition?: 'fail' | 'pass' - memberRequirementsForUsers?: 'pass' | 'fail' + memberRequirementsForUsers?: 'fail' | 'pass' + mode?: 'all' | 'any' } diff --git a/bots/discord/src/classes/CommandError.ts b/bots/discord/src/classes/CommandError.ts index 23574b0..94e9733 100644 --- a/bots/discord/src/classes/CommandError.ts +++ b/bots/discord/src/classes/CommandError.ts @@ -1,9 +1,9 @@ -import { createErrorEmbed } from '$/utils/discord/embeds' +import { createErrorEmbed } from '../utils/discord/embeds' export default class CommandError extends Error { type: CommandErrorType - constructor(type: CommandErrorType, message?: string) { + constructor(type: CommandErrorType, message: string = ErrorMessageMap[type]) { super(message) this.name = 'CommandError' this.type = type @@ -15,19 +15,34 @@ export default class CommandError extends Error { } export enum CommandErrorType { - Generic, + Generic = 1, + InteractionNotInGuild, + InteractionDataMismatch, + FetchManagerNotFound, + FetchNotFound, + RequirementsNotMet = 100, MissingArgument, InvalidArgument, - InvalidUser, - InvalidChannel, - InvalidDuration, } const ErrorTitleMap: Record = { [CommandErrorType.Generic]: 'An exception was thrown', + [CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers', + [CommandErrorType.InteractionDataMismatch]: 'Command data mismatch', + [CommandErrorType.FetchManagerNotFound]: 'Cannot fetch data (manager not found)', + [CommandErrorType.FetchNotFound]: 'Cannot fetch data (source not found)', + [CommandErrorType.RequirementsNotMet]: 'Command requirements not met', [CommandErrorType.MissingArgument]: 'Missing argument', [CommandErrorType.InvalidArgument]: 'Invalid argument', - [CommandErrorType.InvalidUser]: 'Invalid user', - [CommandErrorType.InvalidChannel]: 'Invalid channel', - [CommandErrorType.InvalidDuration]: 'Invalid duration', +} + +const ErrorMessageMap: Record = { + [CommandErrorType.Generic]: 'An generic exception was thrown.', + [CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers.', + [CommandErrorType.InteractionDataMismatch]: 'Interaction command data does not match the expected command data.', + [CommandErrorType.FetchManagerNotFound]: 'Cannot fetch required data.', + [CommandErrorType.FetchNotFound]: 'Cannot fetch target.', + [CommandErrorType.RequirementsNotMet]: 'You do not meet the requirements to use this command.', + [CommandErrorType.MissingArgument]: 'You are missing a required argument.', + [CommandErrorType.InvalidArgument]: 'You provided an invalid argument.', } diff --git a/bots/discord/src/commands/admin/slash-commands.ts b/bots/discord/src/commands/admin/slash-commands.ts index 30d5d27..9970ac4 100644 --- a/bots/discord/src/commands/admin/slash-commands.ts +++ b/bots/discord/src/commands/admin/slash-commands.ts @@ -42,7 +42,7 @@ export default new AdminCommand({ const { global: globalCommands, guild: guildCommands } = Object.groupBy( Object.values(context.discord.commands), - cmd => (cmd.global ? 'global' : 'guild'), + cmd => (cmd.isGuildSpecific() ? 'guild' : 'global'), ) const { diff --git a/bots/discord/src/commands/fun/coinflip.ts b/bots/discord/src/commands/fun/coinflip.ts index df8ffcd..ec0b341 100644 --- a/bots/discord/src/commands/fun/coinflip.ts +++ b/bots/discord/src/commands/fun/coinflip.ts @@ -6,7 +6,7 @@ import { applyCommonEmbedStyles } from '$/utils/discord/embeds' export default new Command({ name: 'coinflip', description: 'Do a coinflip!', - global: true, + type: Command.Type.ChatGlobal, requirements: { defaultCondition: 'pass', }, diff --git a/bots/discord/src/commands/moderation/ban.ts b/bots/discord/src/commands/moderation/ban.ts index 2f18f9b..895ea9d 100644 --- a/bots/discord/src/commands/moderation/ban.ts +++ b/bots/discord/src/commands/moderation/ban.ts @@ -35,7 +35,7 @@ export default new ModerationCommand({ if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'You cannot ban a user with a role equal to or higher than yours.', ) } diff --git a/bots/discord/src/commands/moderation/mute.ts b/bots/discord/src/commands/moderation/mute.ts index 2b4472f..c7549a6 100644 --- a/bots/discord/src/commands/moderation/mute.ts +++ b/bots/discord/src/commands/moderation/mute.ts @@ -37,14 +37,14 @@ export default new ModerationCommand({ if (Number.isInteger(duration) && duration! < 1) throw new CommandError( - CommandErrorType.InvalidDuration, + CommandErrorType.InvalidArgument, 'The duration must be at least 1 millisecond long.', ) const expires = Math.max(duration, Date.now() + duration) if (!member) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'The provided member is not in the server or does not exist.', ) @@ -53,7 +53,7 @@ export default new ModerationCommand({ if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'You cannot mute a user with a role equal to or higher than yours.', ) diff --git a/bots/discord/src/commands/moderation/purge.ts b/bots/discord/src/commands/moderation/purge.ts index a956b69..dc218d0 100644 --- a/bots/discord/src/commands/moderation/purge.ts +++ b/bots/discord/src/commands/moderation/purge.ts @@ -32,7 +32,7 @@ export default new ModerationCommand({ const channel = interaction.channel! if (!channel.isTextBased()) - throw new CommandError(CommandErrorType.InvalidChannel, 'The supplied channel is not a text channel.') + throw new CommandError(CommandErrorType.InvalidArgument, 'The supplied channel is not a text channel.') const embed = applyCommonEmbedStyles( new EmbedBuilder({ diff --git a/bots/discord/src/commands/moderation/role-preset.ts b/bots/discord/src/commands/moderation/role-preset.ts index c65b1bb..9313fef 100644 --- a/bots/discord/src/commands/moderation/role-preset.ts +++ b/bots/discord/src/commands/moderation/role-preset.ts @@ -45,7 +45,7 @@ export default new ModerationCommand({ if (!member) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'The provided member is not in the server or does not exist.', ) @@ -56,13 +56,13 @@ export default new ModerationCommand({ const duration = durationInput ? parseDuration(durationInput) : Infinity if (Number.isInteger(duration) && duration! < 1) throw new CommandError( - CommandErrorType.InvalidDuration, + CommandErrorType.InvalidArgument, 'The duration must be at least 1 millisecond long.', ) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'You cannot apply a role preset to a user with a role equal to or higher than yours.', ) diff --git a/bots/discord/src/commands/moderation/slowmode.ts b/bots/discord/src/commands/moderation/slowmode.ts index 5e55002..67facf7 100644 --- a/bots/discord/src/commands/moderation/slowmode.ts +++ b/bots/discord/src/commands/moderation/slowmode.ts @@ -27,14 +27,14 @@ export default new ModerationCommand({ if (!channel?.isTextBased() || channel.isDMBased()) throw new CommandError( - CommandErrorType.InvalidChannel, + CommandErrorType.InvalidArgument, 'The supplied channel is not a text channel.', ) - if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.') + if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidArgument, 'Invalid duration.') if (duration < 0 || duration > 36e4) throw new CommandError( - CommandErrorType.InvalidDuration, + CommandErrorType.InvalidArgument, 'Duration out of range, must be between 0s and 6h.', ) diff --git a/bots/discord/src/commands/moderation/unmute.ts b/bots/discord/src/commands/moderation/unmute.ts index fe164b6..d45db30 100644 --- a/bots/discord/src/commands/moderation/unmute.ts +++ b/bots/discord/src/commands/moderation/unmute.ts @@ -20,7 +20,7 @@ export default new ModerationCommand({ const member = await interaction.guild!.members.fetch(user.id) if (!member) throw new CommandError( - CommandErrorType.InvalidUser, + CommandErrorType.InvalidArgument, 'The provided member is not in the server or does not exist.', ) diff --git a/bots/discord/src/context.ts b/bots/discord/src/context.ts index 6623773..97229ba 100644 --- a/bots/discord/src/context.ts +++ b/bots/discord/src/context.ts @@ -17,7 +17,7 @@ export const logger = createLogger({ import * as commands from './commands' import * as schemas from './database/schemas' -import type { default as Command, CommandOptionsOptions } from './classes/Command' +import type { default as Command, CommandOptionsOptions, CommandType } from './classes/Command' export const api = { client: new APIClient({ @@ -83,7 +83,7 @@ export const discord = { }), commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record< string, - Command + Command >, stickyMessages: {} as Record< string, diff --git a/bots/discord/src/events/discord/interactionCreate/chatCommand.ts b/bots/discord/src/events/discord/interactionCreate/chatCommand.ts index b3d1c95..8807d72 100644 --- a/bots/discord/src/events/discord/interactionCreate/chatCommand.ts +++ b/bots/discord/src/events/discord/interactionCreate/chatCommand.ts @@ -8,19 +8,24 @@ withContext(on, 'interactionCreate', async (context, interaction) => { const { logger, discord } = context const command = discord.commands[interaction.commandName] - logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`) + logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via chat`) if (!command) - return void logger.error(`Interaction command ${interaction.commandName} not implemented but registered!!!`) + return void logger.error(`Chat command ${interaction.commandName} not implemented but registered!!!`) try { - logger.debug(`Command ${interaction.commandName} being executed`) + logger.debug(`Command ${interaction.commandName} being executed via chat`) await command.onInteraction(context, interaction) } catch (err) { if (!(err instanceof CommandError)) logger.error(`Error while executing command ${interaction.commandName}:`, err) + await interaction[interaction.replied ? 'followUp' : 'reply']({ embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)], ephemeral: true, }) + + // 100 and up are user errors + if (err instanceof CommandError && err.type < 100) + logger.error(`Command ${interaction.commandName} internally failed with error:`, err) } }) diff --git a/bots/discord/src/events/discord/interactionCreate/contextMenuCommand.ts b/bots/discord/src/events/discord/interactionCreate/contextMenuCommand.ts new file mode 100644 index 0000000..e98aa56 --- /dev/null +++ b/bots/discord/src/events/discord/interactionCreate/contextMenuCommand.ts @@ -0,0 +1,26 @@ +import CommandError from '$/classes/CommandError' +import { createStackTraceEmbed } from '$utils/discord/embeds' +import { on, withContext } from '$utils/discord/events' + +withContext(on, 'interactionCreate', async (context, interaction) => { + if (!interaction.isContextMenuCommand()) return + + const { logger, discord } = context + const command = discord.commands[interaction.commandName] + + logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via context menu`) + if (!command) + return void logger.error(`Context menu command ${interaction.commandName} not implemented but registered!!!`) + + try { + logger.debug(`Command ${interaction.commandName} being executed via context menu`) + await command.onContextMenuInteraction(context, interaction) + } catch (err) { + if (!(err instanceof CommandError)) + logger.error(`Error while executing command ${interaction.commandName}:`, err) + await interaction[interaction.replied ? 'followUp' : 'reply']({ + embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)], + ephemeral: true, + }) + } +}) diff --git a/bots/discord/src/events/discord/messageCreate/messageCommand.ts b/bots/discord/src/events/discord/messageCreate/messageCommand.ts index 3b9440b..328522e 100644 --- a/bots/discord/src/events/discord/messageCreate/messageCommand.ts +++ b/bots/discord/src/events/discord/messageCreate/messageCommand.ts @@ -18,7 +18,7 @@ withContext(on, 'messageCreate', async (context, msg) => { if (!commandName) return const command = discord.commands[commandName] - logger.debug(`Command ${commandName} being invoked by ${msg.author.id}`) + logger.debug(`Command ${commandName} being invoked by ${msg.author.id} via message`) if (!command) return void logger.debug(`Message command ${commandName} not implemented`) const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g @@ -46,7 +46,7 @@ withContext(on, 'messageCreate', async (context, msg) => { } try { - logger.debug(`Command ${commandName} being executed`) + logger.debug(`Command ${commandName} being executed via message`) await command.onMessage(context, msg, args) } catch (err) { if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err)