From 646ec8da87617e6c8f48a89e8054e2cba91da549 Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Tue, 30 Jul 2024 21:05:12 +0700 Subject: [PATCH] feat(bots/discord): framework changes and new features - Migrated to a new command framework which looks better and works better - Fixed commands not being bundled correctly - Added message (prefix) commands with argument validation - Added a new CommandErrorType, for invalid arguments - `/eval` is now a bit safer - Corrected colors for the coinflip embed - `/stop` now works even when the bot is not connected to the API --- bots/discord/config.js | 1 + bots/discord/config.schema.ts | 1 + bots/discord/package.json | 2 +- bots/discord/scripts/build.ts | 2 +- bots/discord/scripts/reload-slash-commands.ts | 53 -- bots/discord/src/classes/Command.ts | 591 ++++++++++++++++++ bots/discord/src/classes/CommandError.ts | 2 + bots/discord/src/commands/admin/eval.ts | 34 + .../src/commands/admin/exception-test.ts | 21 + .../src/commands/admin/slash-commands.ts | 93 +++ bots/discord/src/commands/admin/stop.ts | 24 + bots/discord/src/commands/development/eval.ts | 32 - .../commands/development/exception-test.ts | 36 -- bots/discord/src/commands/development/stop.ts | 35 -- bots/discord/src/commands/fun/coinflip.ts | 33 +- bots/discord/src/commands/fun/reply.ts | 62 +- bots/discord/src/commands/moderation/ban.ts | 76 +-- bots/discord/src/commands/moderation/cure.ts | 35 +- bots/discord/src/commands/moderation/mute.ts | 75 +-- bots/discord/src/commands/moderation/purge.ts | 58 +- .../src/commands/moderation/role-preset.ts | 95 ++- .../src/commands/moderation/slowmode.ts | 59 +- bots/discord/src/commands/moderation/unban.ts | 42 +- .../discord/src/commands/moderation/unmute.ts | 35 +- bots/discord/src/commands/types.ts | 56 -- bots/discord/src/context.ts | 16 +- .../discord/interactionCreate/chatCommand.ts | 66 +- .../discord/messageCreate/messageCommand.ts | 53 ++ .../discord/messageCreate/scanMessage.ts | 8 +- .../messageReactionAdd/correctResponse.ts | 2 +- bots/discord/src/events/discord/ready.ts | 4 +- bots/discord/src/utils/discord/messageScan.ts | 24 +- bots/discord/src/utils/discord/moderation.ts | 14 +- bots/discord/src/utils/discord/permissions.ts | 7 +- bots/discord/src/utils/discord/rolePresets.ts | 4 +- bots/discord/src/utils/fs.ts | 18 +- 36 files changed, 1153 insertions(+), 616 deletions(-) delete mode 100644 bots/discord/scripts/reload-slash-commands.ts create mode 100644 bots/discord/src/classes/Command.ts create mode 100644 bots/discord/src/commands/admin/eval.ts create mode 100644 bots/discord/src/commands/admin/exception-test.ts create mode 100644 bots/discord/src/commands/admin/slash-commands.ts create mode 100644 bots/discord/src/commands/admin/stop.ts delete mode 100644 bots/discord/src/commands/development/eval.ts delete mode 100644 bots/discord/src/commands/development/exception-test.ts delete mode 100644 bots/discord/src/commands/development/stop.ts delete mode 100644 bots/discord/src/commands/types.ts create mode 100644 bots/discord/src/events/discord/messageCreate/messageCommand.ts diff --git a/bots/discord/config.js b/bots/discord/config.js index 5b7eedd..bddf96d 100644 --- a/bots/discord/config.js +++ b/bots/discord/config.js @@ -4,6 +4,7 @@ * @type {import('./config.schema').Config} */ export default { + prefix: '!', admin: { users: ['USER_ID_HERE'], roles: { diff --git a/bots/discord/config.schema.ts b/bots/discord/config.schema.ts index fb05df1..8738981 100644 --- a/bots/discord/config.schema.ts +++ b/bots/discord/config.schema.ts @@ -1,6 +1,7 @@ import type { BaseMessageOptions } from 'discord.js' export type Config = { + prefix?: string admin?: { users?: string[] roles?: Record diff --git a/bots/discord/package.json b/bots/discord/package.json index 901d29c..24bb0f1 100644 --- a/bots/discord/package.json +++ b/bots/discord/package.json @@ -43,4 +43,4 @@ "discord-api-types": "^0.37.92", "drizzle-kit": "^0.22.8" } -} \ No newline at end of file +} diff --git a/bots/discord/scripts/build.ts b/bots/discord/scripts/build.ts index ed335c7..47814d7 100644 --- a/bots/discord/scripts/build.ts +++ b/bots/discord/scripts/build.ts @@ -1,5 +1,5 @@ import { createLogger } from '@revanced/bot-shared' -import { cp, rename, rm } from 'fs/promises' +import { cp, rm } from 'fs/promises' const logger = createLogger() diff --git a/bots/discord/scripts/reload-slash-commands.ts b/bots/discord/scripts/reload-slash-commands.ts deleted file mode 100644 index ad1054f..0000000 --- a/bots/discord/scripts/reload-slash-commands.ts +++ /dev/null @@ -1,53 +0,0 @@ -console.log('Deprecated. New implementation to be done.') -process.exit(1) - -// import { REST } from '@discordjs/rest' -// import { getMissingEnvironmentVariables } from '@revanced/bot-shared' -// import { Routes } from 'discord-api-types/v9' -// import type { -// RESTGetCurrentApplicationResult, -// RESTPutAPIApplicationCommandsResult, -// RESTPutAPIApplicationGuildCommandsResult, -// } from 'discord.js' -// import { config, discord, logger } from '../src/context' - -// // Check if token exists - -// const missingEnvs = getMissingEnvironmentVariables(['DISCORD_TOKEN']) -// if (missingEnvs.length) { -// for (const env of missingEnvs) logger.fatal(`${env} is not defined in environment variables`) -// process.exit(1) -// } - -// // Group commands by global and guild - -// const { global: globalCommands = [], guild: guildCommands = [] } = Object.groupBy(Object.values(discord.commands), c => -// c.global ? 'global' : 'guild', -// ) - -// // Set commands - -// const rest = new REST({ version: '10' }).setToken(process.env['DISCORD_TOKEN']!) - -// try { -// const app = (await rest.get(Routes.currentApplication())) as RESTGetCurrentApplicationResult -// const data = (await rest.put(Routes.applicationCommands(app.id), { -// body: globalCommands.map(({ data }) => { -// if (!data.dm_permission) data.dm_permission = true -// logger.warn(`Command ${data.name} has no dm_permission set, forcing to true as it is a global command`) -// return data -// }), -// })) as RESTPutAPIApplicationCommandsResult - -// logger.info(`Reloaded ${data.length} global commands`) - -// for (const guildId of config.guilds) { -// const data = (await rest.put(Routes.applicationGuildCommands(app.id, guildId), { -// body: guildCommands.map(x => x.data), -// })) as RESTPutAPIApplicationGuildCommandsResult - -// logger.info(`Reloaded ${data.length} guild commands for guild ${guildId}`) -// } -// } catch (e) { -// logger.fatal(e) -// } diff --git a/bots/discord/src/classes/Command.ts b/bots/discord/src/classes/Command.ts new file mode 100644 index 0000000..7baf466 --- /dev/null +++ b/bots/discord/src/classes/Command.ts @@ -0,0 +1,591 @@ +import { ApplicationCommandOptionType } from 'discord.js' + +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, + CommandInteractionOption, + GuildMember, + Message, + RESTPostAPIChatInputApplicationCommandsJSONBody, + Role, + User, +} from 'discord.js' + +export default class Command< + Global extends boolean = false, + Options extends CommandOptionsOptions | undefined = undefined, + AllowMessageCommand extends boolean = false, +> { + name: string + description: string + requirements?: CommandRequirements + options?: Options + global?: Global + #execute: CommandExecuteFunction + + static OptionType = ApplicationCommandOptionType + + constructor({ + name, + description, + requirements, + options, + global, + execute, + }: CommandOptions) { + this.name = name + this.description = description + this.requirements = requirements + this.options = options + this.global = global + this.#execute = execute + } + + async onMessage( + context: typeof import('../context'), + 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.')], + }) + + 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 options = this.options + ? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter< + NonNullable + >) + : undefined + + // @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough + return await this.#execute({ ...context, executor }, msg, options) + } + + async #resolveMessageOptions(msg: Message, options: CommandOptionsOptions, args: CommandArguments) { + const iterableOptions = Object.entries(options) + const _options = {} as unknown + + for (let i = 0; i < iterableOptions.length; i++) { + const [name, option] = iterableOptions[i]! + const { type, required, description } = option + const isSubcommandLikeOption = + type === ApplicationCommandOptionType.Subcommand || + type === ApplicationCommandOptionType.SubcommandGroup + + const arg = args[i] + + const expectedType = `${ApplicationCommandOptionType[type]}${required ? '' : '?'}` + const argExplainationString = `\n-# **${name}**: ${description}` + const choicesString = + 'choices' in option && option.choices + ? `\n\n-# **AVAILABLE CHOICES**\n${option.choices.map(({ value }) => `- ${value}`).join('\n')}` + : '' + + if (isSubcommandLikeOption && !arg) + throw new CommandError( + CommandErrorType.MissingArgument, + `Missing required subcommand.\n\n-# **AVAILABLE SUBCOMMANDS**\n${iterableOptions.map(([name, { description }]) => `- **${name}**: ${description}`).join('\n')}`, + ) + + if (required && !arg) + throw new CommandError( + CommandErrorType.MissingArgument, + `Missing required argument **${name}** with type **${expectedType}**.${argExplainationString}${choicesString}`, + ) + + if (typeof arg === 'object' && arg.type !== type) + throw new CommandError( + CommandErrorType.InvalidArgument, + `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)) + throw new CommandError( + CommandErrorType.InvalidArgument, + `Invalid choice for argument **${name}**.\n${argExplainationString}\n\n${choicesString}\n`, + ) + + const argValue = typeof arg === 'string' ? arg : arg?.id + + if (argValue && arg) { + if (isSubcommandLikeOption) { + const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)! + + // @ts-expect-error: Not smart enough, TypeScript :( + _options[subcommandName] = await this.#resolveMessageOptions( + msg, + (subcommandOption as CommandSubcommandLikeOption).options, + args.slice(i + 1), + ) + + break + } + + if ( + (type === ApplicationCommandOptionType.Channel || + type === ApplicationCommandOptionType.User || + type === ApplicationCommandOptionType.Role) && + Number.isNaN(Number(argValue)) + ) + throw new CommandError( + CommandErrorType.InvalidArgument, + `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.Boolean && + !['true', 'false', 'yes', 'no', 'y', 'n', 't', 'f'].includes(argValue) + ) + throw new CommandError( + CommandErrorType.InvalidArgument, + `Invalid boolean for argument **${name}**.${argExplainationString}`, + ) + + // @ts-expect-error: Not smart enough, TypeScript :( + _options[name] = + type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer + ? Number(argValue) + : type === ApplicationCommandOptionType.Boolean + ? argValue[0] === 't' || argValue[0] === 'y' + : type === ApplicationCommandOptionType.Channel + ? await msg.client.channels.fetch(argValue) + : type === ApplicationCommandOptionType.User + ? await msg.client.users.fetch(argValue) + : type === ApplicationCommandOptionType.Role + ? await msg.guild?.roles.fetch(argValue) + : argValue + } + } + + return _options + } + + async onInteraction( + context: typeof import('../context'), + interaction: ChatInputCommandInteraction, + ): Promise { + const { logger } = context + + 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.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 options = this.options + ? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter< + NonNullable + >) + : 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.', + ), + ], + }) + + // @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough + return await this.#execute({ ...context, executor }, interaction, options) + } + + async #resolveInteractionOptions( + interaction: ChatInputCommandInteraction, + options: readonly CommandInteractionOption[] = interaction.options.data, + ) { + const _options = {} as unknown + + if (this.options) + for (const { name, type, value } of options) { + if (this.options[name]?.type !== type) return null + + if ( + type === ApplicationCommandOptionType.Subcommand || + type === ApplicationCommandOptionType.SubcommandGroup + ) { + const subOptions = Object.entries((this.options[name] as CommandSubcommandLikeOption).options) + + // @ts-expect-error: Not smart enough, TypeScript :( + _options[name] = await this.#resolveInteractionOptions(interaction, subOptions) + + break + } + + if (!value) continue + + // @ts-expect-error: Not smart enough, TypeScript :( + _options[name] = + type === ApplicationCommandOptionType.Channel + ? await interaction.client.channels.fetch(value as string) + : type === ApplicationCommandOptionType.User + ? await interaction.client.users.fetch(value as string) + : type === ApplicationCommandOptionType.Role + ? await interaction.guild?.roles.fetch(value as string) + : value + } + + return _options + } + + async canExecute(executor: User | GuildMember, channelId: string): Promise { + if (!this.requirements) return false + + const { + adminOnly, + channels, + roles, + permissions, + users, + mode = 'all', + defaultCondition = 'fail', + memberRequirementsForUsers = 'pass', + } = this.requirements + + const member = this.global ? null : (executor as GuildMember) + const bDefCond = defaultCondition !== 'fail' + const bMemReqForUsers = memberRequirementsForUsers !== 'fail' + + const conditions = [ + adminOnly ? isAdmin(executor) : 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, + ] + + if (mode === 'all' && conditions.some(condition => !condition)) return false + if (mode === 'any' && conditions.every(condition => !condition)) return false + + return true + } + + get json(): RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } { + return { + name: this.name, + 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], + } + } + + #transformOptions(optionsObject: Record) { + const options: RESTPostAPIChatInputApplicationCommandsJSONBody['options'] = [] + + for (const [name, option] of Object.entries(optionsObject)) { + options.push({ + // biome-ignore lint/suspicious/noExplicitAny: Good enough work here + type: option.type as any, + name, + description: option.description, + required: option.required, + ...(option.type === ApplicationCommandOptionType.Subcommand || + option.type === ApplicationCommandOptionType.SubcommandGroup + ? { + options: this.#transformOptions((option as CommandSubcommandLikeOption).options), + } + : {}), + ...(option.type === ApplicationCommandOptionType.Channel ? { channel_types: option.types } : {}), + ...(option.type === ApplicationCommandOptionType.Integer || + option.type === ApplicationCommandOptionType.Number + ? { + min_value: option.min, + max_value: option.max, + choices: option.choices, + autocomplete: option.autocomplete, + } + : {}), + ...(option.type === ApplicationCommandOptionType.String + ? { + min_length: option.minLength, + max_length: option.maxLength, + choices: option.choices, + autocomplete: option.autocomplete, + } + : {}), + }) + } + + return options + } +} + +export class ModerationCommand< + Options extends CommandOptionsOptions, + AllowMessageCommand extends boolean = true, +> extends Command { + constructor(options: ExtendedCommandOptions) { + super({ + ...options, + requirements: { + ...options.requirements, + defaultCondition: 'pass', + roles: (config.moderation?.roles ?? []).concat(options.requirements?.roles ?? []), + }, + // @ts-expect-error: No thanks + allowMessageCommand: options.allowMessageCommand ?? true, + global: false, + }) + } +} + +export class AdminCommand extends Command< + true, + Options, + AllowMessageCommand +> { + constructor(options: ExtendedCommandOptions) { + super({ + ...options, + requirements: { + ...options.requirements, + adminOnly: true, + defaultCondition: 'pass', + }, + global: true, + }) + } +} + +/* TODO: + APIApplicationCommandAttachmentOption + APIApplicationCommandMentionableOption + APIApplicationCommandRoleOption +*/ + +export interface CommandOptions< + Global extends boolean, + Options extends CommandOptionsOptions | undefined, + AllowMessageCommand extends boolean, +> { + name: string + description: string + requirements?: CommandRequirements + options?: Options + execute: CommandExecuteFunction + global?: Global + allowMessageCommand?: AllowMessageCommand +} + +export type CommandArguments = Array + +export type CommandSpecialArgument = { + type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType] + id: string +} + +export const CommandSpecialArgumentType = { + Channel: ApplicationCommandOptionType.Channel, + Role: ApplicationCommandOptionType.Role, + User: ApplicationCommandOptionType.User, +} + +type ExtendedCommandOptions< + Global extends boolean, + Options extends CommandOptionsOptions, + AllowMessageCommand extends boolean, +> = Omit, 'global'> & { + requirements?: Omit['requirements'], 'defaultCondition'> +} + +export type CommandOptionsOptions = Record + +type CommandExecuteFunction< + Global extends boolean, + Options extends CommandOptionsOptions | undefined, + AllowMessageCommand extends boolean, +> = ( + context: CommandContext, + trigger: If< + AllowMessageCommand, + Message> | ChatInputCommandInteraction>, + ChatInputCommandInteraction> + >, + options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter : never, +) => Promise | unknown + +type If = T extends true ? U : V +type InvertBoolean = If + +type CommandExecuteFunctionOptionsParameter = { + [K in keyof Options]: Options[K]['type'] extends + | ApplicationCommandOptionType.Subcommand + | ApplicationCommandOptionType.SubcommandGroup + ? // @ts-expect-error: Shut up, it works + CommandExecuteFunctionOptionsParameter | undefined + : If< + Options[K]['required'], + CommandOptionValueMap[Options[K]['type']], + CommandOptionValueMap[Options[K]['type']] | undefined + > +} + +type CommandContext = typeof import('../context') & { + executor: CommandExecutor +} + +type CommandOptionValueMap = { + [ApplicationCommandOptionType.Boolean]: boolean + [ApplicationCommandOptionType.Channel]: Channel + [ApplicationCommandOptionType.Integer]: number + [ApplicationCommandOptionType.Number]: number + [ApplicationCommandOptionType.String]: string + [ApplicationCommandOptionType.User]: User + [ApplicationCommandOptionType.Role]: Role + [ApplicationCommandOptionType.Subcommand]: never + [ApplicationCommandOptionType.SubcommandGroup]: never +} + +type CommandOption = + | CommandBooleanOption + | CommandChannelOption + | CommandIntegerOption + | CommandNumberOption + | CommandStringOption + | CommandUserOption + | CommandRoleOption + | CommandSubcommandOption + | CommandSubcommandGroupOption + +type CommandExecutor = If + +type CommandOptionBase = { + type: Type + description: string + required?: boolean +} + +type CommandBooleanOption = CommandOptionBase + +type CommandChannelOption = CommandOptionBase & { + types: APIApplicationCommandChannelOption['channel_types'] +} + +interface CommandOptionChoice { + name: string + value: ValueType +} + +type CommandOptionWithAutocompleteOrChoicesWrapper< + Base extends CommandOptionBase, + ChoiceType extends CommandOptionChoice, +> = + | (Base & { + autocomplete: true + choices?: never + }) + | (Base & { + autocomplete?: false + choices?: ChoiceType[] | readonly ChoiceType[] + }) + +type CommandIntegerOption = CommandOptionWithAutocompleteOrChoicesWrapper< + CommandOptionBase, + CommandOptionChoice +> & { + min?: number + max?: number +} + +type CommandNumberOption = CommandOptionWithAutocompleteOrChoicesWrapper< + CommandOptionBase, + CommandOptionChoice +> & { + min?: number + max?: number +} + +type CommandStringOption = CommandOptionWithAutocompleteOrChoicesWrapper< + CommandOptionBase, + CommandOptionChoice +> & { + minLength?: number + maxLength?: number +} + +type CommandUserOption = CommandOptionBase + +type CommandRoleOption = CommandOptionBase + +type SubcommandLikeApplicationCommandOptionType = + | ApplicationCommandOptionType.Subcommand + | ApplicationCommandOptionType.SubcommandGroup + +interface CommandSubcommandLikeOption< + Type extends SubcommandLikeApplicationCommandOptionType = SubcommandLikeApplicationCommandOptionType, +> extends CommandOptionBase { + options: CommandOptionsOptions + required?: never +} + +type CommandSubcommandOption = CommandSubcommandLikeOption +type CommandSubcommandGroupOption = CommandSubcommandLikeOption + +export type CommandRequirements = Filter & { + mode?: 'all' | 'any' + adminOnly?: boolean + permissions?: bigint + defaultCondition?: 'fail' | 'pass' + memberRequirementsForUsers?: 'pass' | 'fail' +} diff --git a/bots/discord/src/classes/CommandError.ts b/bots/discord/src/classes/CommandError.ts index 48ee202..23574b0 100644 --- a/bots/discord/src/classes/CommandError.ts +++ b/bots/discord/src/classes/CommandError.ts @@ -17,6 +17,7 @@ export default class CommandError extends Error { export enum CommandErrorType { Generic, MissingArgument, + InvalidArgument, InvalidUser, InvalidChannel, InvalidDuration, @@ -25,6 +26,7 @@ export enum CommandErrorType { const ErrorTitleMap: Record = { [CommandErrorType.Generic]: 'An exception was thrown', [CommandErrorType.MissingArgument]: 'Missing argument', + [CommandErrorType.InvalidArgument]: 'Invalid argument', [CommandErrorType.InvalidUser]: 'Invalid user', [CommandErrorType.InvalidChannel]: 'Invalid channel', [CommandErrorType.InvalidDuration]: 'Invalid duration', diff --git a/bots/discord/src/commands/admin/eval.ts b/bots/discord/src/commands/admin/eval.ts new file mode 100644 index 0000000..8d80997 --- /dev/null +++ b/bots/discord/src/commands/admin/eval.ts @@ -0,0 +1,34 @@ +import { inspect } from 'util' +import { runInNewContext } from 'vm' +import { ApplicationCommandOptionType } from 'discord.js' + +import { AdminCommand } from '$/classes/Command' +import { createSuccessEmbed } from '$/utils/discord/embeds' + +export default new AdminCommand({ + name: 'eval', + description: 'Make the bot less sentient by evaluating code', + options: { + code: { + description: 'The code to evaluate', + type: ApplicationCommandOptionType.String, + required: true, + }, + ['show-hidden']: { + description: 'Show hidden properties', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + }, + async execute(context, trigger, { code, 'show-hidden': showHidden }) { + await trigger.reply({ + ephemeral: true, + embeds: [ + createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({ + name: 'Result', + value: `\`\`\`js\n${inspect(runInNewContext(code, { client: trigger.client, context, trigger }), { depth: 1, showHidden, getters: true, numericSeparator: true, showProxy: true })}\`\`\``, + }), + ], + }) + }, +}) diff --git a/bots/discord/src/commands/admin/exception-test.ts b/bots/discord/src/commands/admin/exception-test.ts new file mode 100644 index 0000000..68ac9c8 --- /dev/null +++ b/bots/discord/src/commands/admin/exception-test.ts @@ -0,0 +1,21 @@ +import { ApplicationCommandOptionType } from 'discord.js' + +import { AdminCommand } from '$/classes/Command' +import CommandError, { CommandErrorType } from '$/classes/CommandError' + +export default new AdminCommand({ + name: 'exception-test', + description: 'Makes the bot intentionally hate you by throwing an exception', + options: { + type: { + description: 'The type of exception to throw', + type: ApplicationCommandOptionType.String, + required: true, + choices: Object.keys(CommandErrorType).map(k => ({ name: k, value: k })), + }, + }, + async execute(_, __, { type }) { + if (type === 'Process') throw new Error('Intentional process exception') + throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], 'Intentional bot design') // ;) + }, +}) diff --git a/bots/discord/src/commands/admin/slash-commands.ts b/bots/discord/src/commands/admin/slash-commands.ts new file mode 100644 index 0000000..30d5d27 --- /dev/null +++ b/bots/discord/src/commands/admin/slash-commands.ts @@ -0,0 +1,93 @@ +import { ApplicationCommandOptionType, Routes } from 'discord.js' + +import { AdminCommand } from '$/classes/Command' +import CommandError, { CommandErrorType } from '$/classes/CommandError' + +import { createSuccessEmbed } from '$/utils/discord/embeds' + +const SubcommandOptions = { + where: { + description: 'Where to register the commands', + type: ApplicationCommandOptionType.String, + choices: [ + { name: 'globally', value: 'global' }, + { name: 'this server', value: 'server' }, + ], + required: true, + }, +} as const + +export default new AdminCommand({ + name: 'slash-commands', + description: 'Register or delete slash commands', + options: { + register: { + description: 'Register slash commands', + type: ApplicationCommandOptionType.Subcommand, + options: SubcommandOptions, + }, + delete: { + description: 'Delete slash commands', + type: ApplicationCommandOptionType.Subcommand, + options: SubcommandOptions, + }, + }, + allowMessageCommand: true, + async execute(context, trigger, { delete: deleteOption, register }) { + const action = register ? 'register' : 'delete' + const { where } = (deleteOption ?? register)! + + if (!trigger.inGuild()) + throw new CommandError(CommandErrorType.Generic, 'This command can only be used in a server.') + + const { global: globalCommands, guild: guildCommands } = Object.groupBy( + Object.values(context.discord.commands), + cmd => (cmd.global ? 'global' : 'guild'), + ) + + const { + client, + client: { rest }, + } = trigger + + let response: string | undefined + + switch (action) { + case 'register': + if (where === 'global') { + response = 'Registered global slash commands' + + await rest.put(Routes.applicationCommands(client.application.id), { + body: globalCommands?.map(c => c.json), + }) + } else { + response = 'Registered slash commands on this server' + + await rest.put(Routes.applicationGuildCommands(client.application.id, trigger.guildId), { + body: guildCommands?.map(c => c.json), + }) + } + + break + + case 'delete': + if (where === 'global') { + response = 'Deleted global slash commands' + + await rest.put(Routes.applicationCommands(client.application.id), { + body: [], + }) + } else { + response = 'Deleted slash commands on this server' + + await rest.put(Routes.applicationGuildCommands(client.application.id, trigger.guildId), { + body: [], + }) + } + + break + } + + await trigger.reply({ embeds: [createSuccessEmbed(response!)] }) + }, +}) diff --git a/bots/discord/src/commands/admin/stop.ts b/bots/discord/src/commands/admin/stop.ts new file mode 100644 index 0000000..e4dd6c7 --- /dev/null +++ b/bots/discord/src/commands/admin/stop.ts @@ -0,0 +1,24 @@ +import { AdminCommand } from '$/classes/Command' + +export default new AdminCommand({ + name: 'stop', + description: "You don't want to run this unless the bot starts to go insane, and like, you really need to stop it.", + async execute({ api, logger, executor }, trigger) { + api.intentionallyDisconnecting = true + + logger.fatal('Stopping bot...') + trigger.reply({ + content: 'Stopping... (I will go offline once done)', + ephemeral: true, + }) + + if (!api.client.disconnected) api.client.disconnect() + logger.warn('Disconnected from API') + + trigger.client.destroy() + logger.warn('Disconnected from Discord API') + + logger.info(`Bot stopped, requested by ${executor.id}`) + process.exit(0) + }, +}) diff --git a/bots/discord/src/commands/development/eval.ts b/bots/discord/src/commands/development/eval.ts deleted file mode 100644 index 59a9570..0000000 --- a/bots/discord/src/commands/development/eval.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { inspect } from 'util' -import { SlashCommandBuilder } from 'discord.js' - -import { createSuccessEmbed } from '$/utils/discord/embeds' -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder() - .setName('eval') - .setDescription('Make the bot less sentient by evaluating code') - .addStringOption(option => option.setName('code').setDescription('The code to evaluate').setRequired(true)) - .setDMPermission(true) - .toJSON(), - - adminOnly: true, - global: true, - - async execute(_, interaction) { - const code = interaction.options.getString('code', true) - - await interaction.reply({ - ephemeral: true, - embeds: [ - createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({ - name: 'Result', - // biome-ignore lint/security/noGlobalEval: Deal with it - value: `\`\`\`js\n${inspect(eval(code), { depth: 1 })}\`\`\``, - }), - ], - }) - }, -} satisfies Command diff --git a/bots/discord/src/commands/development/exception-test.ts b/bots/discord/src/commands/development/exception-test.ts deleted file mode 100644 index cde89ae..0000000 --- a/bots/discord/src/commands/development/exception-test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js' - -import CommandError, { CommandErrorType } from '$/classes/CommandError' -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder() - .setName('exception-test') - .setDescription('Makes the bot intentionally hate you by throwing an exception') - .addStringOption(option => - option - .setName('type') - .setDescription('The type of exception to throw') - .setRequired(true) - .addChoices( - Object.keys(CommandErrorType).map( - k => - ({ - name: k, - value: k, - }) as const, - ), - ), - ) - .setDMPermission(true) - .toJSON(), - - adminOnly: true, - global: true, - - async execute(_, interaction) { - const type = interaction.options.getString('type', true) - if (type === 'Process') throw new Error('Intentional process exception') - throw new CommandError(CommandErrorType[type as keyof typeof CommandErrorType], 'Intentional bot design') // ;) - }, -} satisfies Command diff --git a/bots/discord/src/commands/development/stop.ts b/bots/discord/src/commands/development/stop.ts deleted file mode 100644 index 3e7bbf4..0000000 --- a/bots/discord/src/commands/development/stop.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { SlashCommandBuilder } from 'discord.js' - -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder() - .setName('stop') - .setDescription( - "You don't want to run this unless the bot starts to go insane, and like, you really need to stop it.", - ) - .setDMPermission(true) - .toJSON(), - - adminOnly: true, - global: true, - - async execute({ api, logger }, interaction) { - api.isStopping = true - - logger.fatal('Stopping bot...') - await interaction.reply({ - content: 'Stopping... (I will go offline once done)', - ephemeral: true, - }) - - api.client.disconnect() - logger.warn('Disconnected from API') - - await interaction.client.destroy() - logger.warn('Disconnected from Discord API') - - logger.info(`Bot stopped, requested by ${interaction.user.id}`) - process.exit(0) - }, -} satisfies Command diff --git a/bots/discord/src/commands/fun/coinflip.ts b/bots/discord/src/commands/fun/coinflip.ts index e6e7a95..df8ffcd 100644 --- a/bots/discord/src/commands/fun/coinflip.ts +++ b/bots/discord/src/commands/fun/coinflip.ts @@ -1,32 +1,37 @@ +import { EmbedBuilder } from 'discord.js' + +import Command from '$/classes/Command' import { applyCommonEmbedStyles } from '$/utils/discord/embeds' -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js' - -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder().setName('coinflip').setDescription('Do a coinflip!').setDMPermission(true).toJSON(), +export default new Command({ + name: 'coinflip', + description: 'Do a coinflip!', global: true, - - async execute(_, interaction) { + requirements: { + defaultCondition: 'pass', + }, + allowMessageCommand: true, + async execute(_, trigger) { const result = Math.random() < 0.5 ? ('heads' as const) : ('tails' as const) - const embed = applyCommonEmbedStyles(new EmbedBuilder().setTitle('Flipping... 🪙'), true, false, false) + const embed = applyCommonEmbedStyles(new EmbedBuilder().setTitle('Flipping... 🪙'), false, false, true) - await interaction.reply({ - embeds: [embed.toJSON()], - }) + const reply = await trigger + .reply({ + embeds: [embed.toJSON()], + }) + .then(it => it.fetch()) embed.setTitle(`The coin landed on... **${result.toUpperCase()}**! ${EmojiMap[result]}`) setTimeout( () => - interaction.editReply({ + reply.edit({ embeds: [embed.toJSON()], }), 1500, ) }, -} satisfies Command +}) const EmojiMap: Record<'heads' | 'tails', string> = { heads: '🤯', diff --git a/bots/discord/src/commands/fun/reply.ts b/bots/discord/src/commands/fun/reply.ts index 4a5c530..69a3def 100644 --- a/bots/discord/src/commands/fun/reply.ts +++ b/bots/discord/src/commands/fun/reply.ts @@ -1,44 +1,46 @@ -import { SlashCommandBuilder, type TextBasedChannel } from 'discord.js' +import CommandError, { CommandErrorType } from '$/classes/CommandError' +import { ApplicationCommandOptionType, Message } from 'discord.js' +import { ModerationCommand } from '../../classes/Command' -import { config } from '$/context' -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder() - .setName('reply') - .setDescription('Send a message as the bot') - .addStringOption(option => option.setName('message').setDescription('The message to send').setRequired(true)) - .addStringOption(option => - option - .setName('reference') - .setDescription('The message ID to reply to (use `latest` to reply to the latest message)') - .setRequired(false), - ) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'reply', + description: 'Send a message as the bot', + options: { + message: { + description: 'The message to send', + required: true, + type: ApplicationCommandOptionType.String, + }, + reference: { + description: 'The message ID to reply to (use `latest` to reply to the latest message)', + required: false, + type: ApplicationCommandOptionType.String, + }, }, + allowMessageCommand: false, + async execute({ logger, executor }, trigger, { reference: ref, message: msg }) { + if (trigger instanceof Message) return - global: false, - - async execute({ logger }, interaction) { - const msg = interaction.options.getString('message', true) - const ref = interaction.options.getString('reference') - - const channel = (await interaction.guild!.channels.fetch(interaction.channelId)) as TextBasedChannel - const refMsg = ref?.startsWith('latest') ? (await channel.messages.fetch({ limit: 1 })).at(0)?.id : ref + const channel = await trigger.guild!.channels.fetch(trigger.channelId) + if (!channel?.isTextBased()) + throw new CommandError( + CommandErrorType.InvalidArgument, + 'This command can only be used in or on text channels', + ) + const refMsg = ref?.startsWith('latest') + ? await channel.messages.fetch({ limit: 1 }).then(it => it.first()) + : ref await channel.send({ content: msg, reply: refMsg ? { messageReference: refMsg, failIfNotExists: true } : undefined, }) - logger.info(`User ${interaction.user.tag} made the bot say: ${msg}`) + logger.info(`User ${executor.user.tag} made the bot say: ${msg}`) - await interaction.reply({ + await trigger.reply({ content: 'OK!', ephemeral: true, }) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/ban.ts b/bots/discord/src/commands/moderation/ban.ts index 1b702b9..2f18f9b 100644 --- a/bots/discord/src/commands/moderation/ban.ts +++ b/bots/discord/src/commands/moderation/ban.ts @@ -1,58 +1,58 @@ -import { SlashCommandBuilder } from 'discord.js' - -import type { Command } from '../types' - +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' -import { config } from '$/context' import { createModerationActionEmbed } from '$/utils/discord/embeds' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { parseDuration } from '$/utils/duration' -export default { - data: new SlashCommandBuilder() - .setName('ban') - .setDescription('Ban a user') - .addUserOption(option => option.setName('user').setRequired(true).setDescription('The user to ban')) - .addStringOption(option => option.setName('reason').setDescription('The reason for banning the user')) - .addStringOption(option => - option.setName('dmd').setDescription('Duration to delete messages (must be from 0 to 7 days)'), - ) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'ban', + description: 'Ban a user', + options: { + user: { + description: 'The user to ban', + required: true, + type: ModerationCommand.OptionType.User, + }, + reason: { + description: 'The reason for banning the user', + required: false, + type: ModerationCommand.OptionType.String, + }, + dmd: { + description: 'Duration to delete messages (must be from 0 to 7 days)', + required: false, + type: ModerationCommand.OptionType.String, + }, }, + async execute({ logger, executor }, interaction, { user, reason, dmd }) { + const guild = await interaction.client.guilds.fetch(interaction.guildId) + const member = await guild.members.fetch(user).catch(() => {}) + const moderator = await guild.members.fetch(executor.user) - global: false, + if (member) { + if (!member.bannable) + throw new CommandError(CommandErrorType.Generic, 'This user cannot be banned by the bot.') - async execute({ logger }, interaction) { - const user = interaction.options.getUser('user', true) - const reason = interaction.options.getString('reason') ?? 'No reason provided' - const dmd = interaction.options.getString('dmd') - - const member = await interaction.guild!.members.fetch(user.id) - const moderator = await interaction.guild!.members.fetch(interaction.user.id) - - if (member.bannable) throw new CommandError(CommandErrorType.Generic, 'This user cannot be banned by the bot.') - - if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) - throw new CommandError( - CommandErrorType.InvalidUser, - 'You cannot ban a user with a role equal to or higher than yours.', - ) + if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) + throw new CommandError( + CommandErrorType.InvalidUser, + 'You cannot ban a user with a role equal to or higher than yours.', + ) + } const dms = Math.floor(dmd ? parseDuration(dmd) : 0 / 1000) await interaction.guild!.members.ban(user, { - reason: `Banned by moderator ${interaction.user.tag} (${interaction.user.id}): ${reason}`, + reason: `Banned by moderator ${executor.user.tag} (${executor.id}): ${reason}`, deleteMessageSeconds: dms, }) await sendModerationReplyAndLogs( interaction, - createModerationActionEmbed('Banned', user, interaction.user, reason), + createModerationActionEmbed('Banned', user, executor.user, reason), ) + logger.info( - `${interaction.user.tag} (${interaction.user.id}) banned ${user.tag} (${user.id}) because ${reason}, deleting their messages sent in the previous ${dms}s`, + `${executor.user.tag} (${executor.id}) banned ${user.tag} (${user.id}) because ${reason}, deleting their messages sent in the previous ${dms}s`, ) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/cure.ts b/bots/discord/src/commands/moderation/cure.ts index c00270f..15b144e 100644 --- a/bots/discord/src/commands/moderation/cure.ts +++ b/bots/discord/src/commands/moderation/cure.ts @@ -1,31 +1,24 @@ -import { SlashCommandBuilder } from 'discord.js' - -import type { Command } from '../types' - -import { config } from '$/context' +import { ModerationCommand } from '$/classes/Command' import { createSuccessEmbed } from '$/utils/discord/embeds' import { cureNickname } from '$/utils/discord/moderation' -export default { - data: new SlashCommandBuilder() - .setName('cure') - .setDescription("Cure a member's nickname") - .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to cure')) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'cure', + description: "Cure a member's nickname", + options: { + member: { + description: 'The member to cure', + required: true, + type: ModerationCommand.OptionType.User, + }, }, - - global: false, - - async execute(_, interaction) { - const user = interaction.options.getUser('member', true) - const member = await interaction.guild!.members.fetch(user.id) + async execute(_, interaction, { member: user }) { + const guild = await interaction.client.guilds.fetch(interaction.guildId) + const member = await guild.members.fetch(user) await cureNickname(member) await interaction.reply({ embeds: [createSuccessEmbed(null, `Cured nickname for ${member.toString()}`)], ephemeral: true, }) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/mute.ts b/bots/discord/src/commands/moderation/mute.ts index aeba84d..2fa1313 100644 --- a/bots/discord/src/commands/moderation/mute.ts +++ b/bots/discord/src/commands/moderation/mute.ts @@ -1,44 +1,47 @@ -import { SlashCommandBuilder } from 'discord.js' - +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' -import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets' -import type { Command } from '../types' - -import { config } from '$/context' import { createModerationActionEmbed } from '$/utils/discord/embeds' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' +import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets' import { parseDuration } from '$/utils/duration' -export default { - data: new SlashCommandBuilder() - .setName('mute') - .setDescription('Mute a member') - .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to mute')) - .addStringOption(option => option.setName('reason').setDescription('The reason for muting the member')) - .addStringOption(option => option.setName('duration').setDescription('The duration of the mute')) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'mute', + description: 'Mute a member', + options: { + member: { + description: 'The member to mute', + required: true, + type: ModerationCommand.OptionType.User, + }, + reason: { + description: 'The reason for muting the member', + required: false, + type: ModerationCommand.OptionType.String, + }, + duration: { + description: 'The duration of the mute', + required: false, + type: ModerationCommand.OptionType.String, + }, }, + async execute( + { logger, executor }, + interaction, + { member: user, reason = 'No reason provided', duration: durationInput }, + ) { + const guild = await interaction.client.guilds.fetch(interaction.guildId) + const member = await guild.members.fetch(user.id) + const moderator = await guild.members.fetch(executor.id) + const duration = durationInput ? parseDuration(durationInput) : Infinity - global: false, - - async execute({ logger }, interaction, { isExecutorBotAdmin: isExecutorAdmin }) { - const user = interaction.options.getUser('member', true) - const reason = interaction.options.getString('reason') ?? 'No reason provided' - const duration = interaction.options.getString('duration') - const durationMs = duration ? parseDuration(duration) : null - - if (Number.isInteger(durationMs) && durationMs! < 1) + if (Number.isInteger(duration) && duration! < 1) throw new CommandError( CommandErrorType.InvalidDuration, 'The duration must be at least 1 millisecond long.', ) - const expires = durationMs ? Date.now() + durationMs : null - const moderator = await interaction.guild!.members.fetch(interaction.user.id) - const member = await interaction.guild!.members.fetch(user.id) + const expires = Math.max(duration, Date.now() + duration) if (!member) throw new CommandError( CommandErrorType.InvalidUser, @@ -48,25 +51,25 @@ export default { if (!member.manageable) throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.') - if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0 && !isExecutorAdmin) + if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) throw new CommandError( CommandErrorType.InvalidUser, 'You cannot mute a user with a role equal to or higher than yours.', ) - await applyRolePreset(member, 'mute', durationMs ? Date.now() + durationMs : null) + await applyRolePreset(member, 'mute', expires) await sendModerationReplyAndLogs( interaction, - createModerationActionEmbed('Muted', user, interaction.user, reason, durationMs), + createModerationActionEmbed('Muted', user, executor.user, reason, duration), ) - if (durationMs) + if (duration) setTimeout(() => { removeRolePreset(member, 'mute') - }, durationMs) + }, duration) logger.info( - `Moderator ${interaction.user.tag} (${interaction.user.id}) muted ${user.tag} (${user.id}) until ${expires} because ${reason}`, + `Moderator ${executor.user.tag} (${executor.user.id}) muted ${user.tag} (${user.id}) until ${expires} because ${reason}`, ) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/purge.ts b/bots/discord/src/commands/moderation/purge.ts index 75eb398..8f59a31 100644 --- a/bots/discord/src/commands/moderation/purge.ts +++ b/bots/discord/src/commands/moderation/purge.ts @@ -1,37 +1,32 @@ -import { EmbedBuilder, GuildChannel, SlashCommandBuilder } from 'discord.js' +import { EmbedBuilder, GuildChannel } from 'discord.js' +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' -import { config } from '$/context' import { applyCommonEmbedStyles } from '$/utils/discord/embeds' -import type { Command } from '../types' - -export default { - data: new SlashCommandBuilder() - .setName('purge') - .setDescription('Purge messages from a channel') - .addIntegerOption(option => - option.setName('amount').setDescription('The amount of messages to remove').setMaxValue(100).setMinValue(1), - ) - .addUserOption(option => - option.setName('user').setDescription('The user to remove messages from (needs `until`)'), - ) - .addStringOption(option => - option.setName('until').setDescription('The message ID to remove messages until (overrides `amount`)'), - ) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'purge', + description: 'Purge messages from a channel', + options: { + amount: { + description: 'The amount of messages to remove', + required: false, + type: ModerationCommand.OptionType.Integer, + min: 1, + max: 100, + }, + user: { + description: 'The user to remove messages from (needs `until`)', + required: false, + type: ModerationCommand.OptionType.User, + }, + until: { + description: 'The message ID to remove messages until (overrides `amount`)', + required: false, + type: ModerationCommand.OptionType.String, + }, }, - - global: false, - - async execute({ logger }, interaction) { - const amount = interaction.options.getInteger('amount') - const user = interaction.options.getUser('user') - const until = interaction.options.getString('until') - + async execute({ logger, executor }, interaction, { amount, user, until }) { if (!amount && !until) throw new CommandError(CommandErrorType.MissingArgument, 'Either `amount` or `until` must be provided.') @@ -59,8 +54,9 @@ export default { await channel.bulkDelete(messages, true) logger.info( - `Moderator ${interaction.user.tag} (${interaction.user.id}) purged ${messages.size} messages in #${channel.name} (${channel.id})`, + `Moderator ${executor.user.tag} (${executor.id}) purged ${messages.size} messages in #${channel.name} (${channel.id})`, ) + await reply.edit({ embeds: [ embed.setTitle('Purged messages').setDescription(null).addFields({ @@ -70,4 +66,4 @@ export default { ], }) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/role-preset.ts b/bots/discord/src/commands/moderation/role-preset.ts index 495744b..aa33817 100644 --- a/bots/discord/src/commands/moderation/role-preset.ts +++ b/bots/discord/src/commands/moderation/role-preset.ts @@ -1,49 +1,48 @@ -import { SlashCommandBuilder } from 'discord.js' - +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' import { sendPresetReplyAndLogs } from '$/utils/discord/moderation' import { applyRolePreset, removeRolePreset } from '$/utils/discord/rolePresets' import { parseDuration } from '$/utils/duration' -import type { Command } from '../types' -export default { - data: new SlashCommandBuilder() - .setName('role-preset') - .setDescription('Manage role presets for a member') - .addStringOption(option => - option - .setName('action') - .setRequired(true) - .setDescription('The action to perform') - .addChoices([ - { name: 'apply', value: 'apply' }, - { name: 'remove', value: 'remove' }, - ]), - ) - .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to manage')) - .addStringOption(option => - option.setName('preset').setRequired(true).setDescription('The preset to apply or remove'), - ) - .addStringOption(option => - option.setName('duration').setDescription('The duration to apply the preset for (only for apply action)'), - ) - .toJSON(), - - memberRequirements: { - roles: ['955220417969262612', '973886585294704640'], +const SubcommandOptions = { + member: { + description: 'The member to manage', + required: true, + type: ModerationCommand.OptionType.User, }, + preset: { + description: 'The preset to apply or remove', + required: true, + type: ModerationCommand.OptionType.String, + }, + duration: { + description: 'The duration to apply the preset for (only for apply action)', + required: false, + type: ModerationCommand.OptionType.String, + }, +} as const - global: false, +export default new ModerationCommand({ + name: 'role-preset', + description: 'Manage role presets for a member', + options: { + apply: { + description: 'Apply a role preset to a member', + type: ModerationCommand.OptionType.Subcommand, + options: SubcommandOptions, + }, + remove: { + description: 'Remove a role preset from a member', + type: ModerationCommand.OptionType.Subcommand, + options: SubcommandOptions, + }, + }, + async execute({ logger, executor }, trigger, { apply, remove }) { + let expires: number | undefined + const { member: user, duration: durationInput, preset } = (apply ?? remove)! + const moderator = await trigger.guild!.members.fetch(executor.user.id) + const member = await trigger.guild!.members.fetch(user.id) - async execute({ logger }, interaction, { isExecutorBotAdmin: isExecutorAdmin }) { - const action = interaction.options.getString('action', true) as 'apply' | 'remove' - const user = interaction.options.getUser('member', true) - const preset = interaction.options.getString('preset', true) - const duration = interaction.options.getString('duration') - - let expires: number | null | undefined = undefined - const moderator = await interaction.guild!.members.fetch(interaction.user.id) - const member = await interaction.guild!.members.fetch(user.id) if (!member) throw new CommandError( CommandErrorType.InvalidUser, @@ -53,29 +52,29 @@ export default { if (!member.manageable) throw new CommandError(CommandErrorType.Generic, 'This user cannot be managed by the bot.') - if (action === 'apply') { - const durationMs = duration ? parseDuration(duration) : null - if (Number.isInteger(durationMs) && durationMs! < 1) + if (apply) { + const duration = durationInput ? parseDuration(durationInput) : Infinity + if (Number.isInteger(duration) && duration! < 1) throw new CommandError( CommandErrorType.InvalidDuration, 'The duration must be at least 1 millisecond long.', ) - if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0 && !isExecutorAdmin) + if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) throw new CommandError( CommandErrorType.InvalidUser, 'You cannot apply a role preset to a user with a role equal to or higher than yours.', ) - expires = durationMs ? Date.now() + durationMs : null + expires = Math.max(duration, Date.now() + duration) await applyRolePreset(member, preset, expires) logger.info( - `Moderator ${interaction.user.tag} (${interaction.user.id}) applied role preset ${preset} to ${user.id} until ${expires}`, + `Moderator ${executor.user.tag} (${executor.user.id}) applied role preset ${preset} to ${user.id} until ${expires}`, ) - } else if (action === 'remove') { + } else if (remove) { await removeRolePreset(member, preset) logger.info( - `Moderator ${interaction.user.tag} (${interaction.user.id}) removed role preset ${preset} from ${user.id}`, + `Moderator ${executor.user.tag} (${executor.user.id}) removed role preset ${preset} from ${user.id}`, ) } @@ -84,6 +83,6 @@ export default { removeRolePreset(member, preset) }, expires) - await sendPresetReplyAndLogs(action, interaction, user, preset, expires) + await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/slowmode.ts b/bots/discord/src/commands/moderation/slowmode.ts index 8cc3815..6fe055f 100644 --- a/bots/discord/src/commands/moderation/slowmode.ts +++ b/bots/discord/src/commands/moderation/slowmode.ts @@ -1,39 +1,31 @@ import { createSuccessEmbed } from '$/utils/discord/embeds' import { durationToString, parseDuration } from '$/utils/duration' -import { SlashCommandBuilder } from 'discord.js' - +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' -import { config } from '$/context' -import type { Command } from '../types' +import { ChannelType } from 'discord.js' -export default { - data: new SlashCommandBuilder() - .setName('slowmode') - .setDescription('Set a slowmode for the current channel') - .addStringOption(option => option.setName('duration').setDescription('The duration to set').setRequired(true)) - .addStringOption(option => - option - .setName('channel') - .setDescription('The channel to set the slowmode on (defaults to current channel)') - .setRequired(false), - ) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'slowmode', + description: 'Set a slowmode for a channel', + options: { + duration: { + description: 'The duration to set', + required: true, + type: ModerationCommand.OptionType.String, + }, + channel: { + description: 'The channel to set the slowmode on (defaults to current channel)', + required: false, + type: ModerationCommand.OptionType.Channel, + types: [ChannelType.GuildText], + }, }, + async execute({ logger, executor }, interaction, { duration: durationInput, channel: channelInput }) { + const channel = channelInput ?? (await interaction.guild!.channels.fetch(interaction.channelId)) + const duration = parseDuration(durationInput) - global: false, - - async execute({ logger }, interaction) { - const durationStr = interaction.options.getString('duration', true) - const id = interaction.options.getChannel('channel')?.id ?? interaction.channelId - - const duration = parseDuration(durationStr) - const channel = await interaction.guild!.channels.fetch(id) - - if (!channel?.isTextBased()) + if (!channel?.isTextBased() || channel.isDMBased()) throw new CommandError( CommandErrorType.InvalidChannel, 'The supplied channel is not a text channel or does not exist.', @@ -46,10 +38,7 @@ export default { 'Duration out of range, must be between 0s and 6h.', ) - logger.info(`Setting slowmode to ${duration}ms on ${channel.id}`) - - await channel.setRateLimitPerUser(duration / 1000, `Set by ${interaction.user.tag} (${interaction.user.id})`) - + await channel.setRateLimitPerUser(duration / 1000, `Set by ${executor.user.tag} (${executor.id})`) await interaction.reply({ embeds: [ createSuccessEmbed( @@ -59,7 +48,7 @@ export default { }) logger.info( - `${interaction.user.tag} (${interaction.user.id}) set the slowmode on ${channel.name} (${channel.id}) to ${duration}ms`, + `${executor.user.tag} (${executor.id}) set the slowmode on ${channel.name} (${channel.id}) to ${duration}ms`, ) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/unban.ts b/bots/discord/src/commands/moderation/unban.ts index 5bcfa93..c0c86f7 100644 --- a/bots/discord/src/commands/moderation/unban.ts +++ b/bots/discord/src/commands/moderation/unban.ts @@ -1,33 +1,21 @@ -import { SlashCommandBuilder } from 'discord.js' - -import type { Command } from '../types' - -import { config } from '$/context' +import { ModerationCommand } from '$/classes/Command' import { createModerationActionEmbed } from '$/utils/discord/embeds' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' -export default { - data: new SlashCommandBuilder() - .setName('unban') - .setDescription('Unban a user') - .addUserOption(option => option.setName('user').setRequired(true).setDescription('The user to unban')) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'unban', + description: 'Unban a user', + options: { + user: { + description: 'The user to unban', + required: true, + type: ModerationCommand.OptionType.User, + }, }, + async execute({ logger, executor }, interaction, { user }) { + await interaction.guild!.members.unban(user, `Unbanned by moderator ${executor.user.tag} (${executor.id})`) - global: false, - - async execute({ logger }, interaction) { - const user = interaction.options.getUser('user', true) - - await interaction.guild!.members.unban( - user, - `Unbanned by moderator ${interaction.user.tag} (${interaction.user.id})`, - ) - - await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unbanned', user, interaction.user)) - logger.info(`${interaction.user.tag} (${interaction.user.id}) unbanned ${user.tag} (${user.id})`) + await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unbanned', user, executor.user)) + logger.info(`${executor.user.tag} (${executor.id}) unbanned ${user.tag} (${user.id})`) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/moderation/unmute.ts b/bots/discord/src/commands/moderation/unmute.ts index 2255a09..fe164b6 100644 --- a/bots/discord/src/commands/moderation/unmute.ts +++ b/bots/discord/src/commands/moderation/unmute.ts @@ -1,29 +1,22 @@ -import { SlashCommandBuilder } from 'discord.js' - +import { ModerationCommand } from '$/classes/Command' import CommandError, { CommandErrorType } from '$/classes/CommandError' -import { config } from '$/context' import { appliedPresets } from '$/database/schemas' import { createModerationActionEmbed } from '$/utils/discord/embeds' import { sendModerationReplyAndLogs } from '$/utils/discord/moderation' import { removeRolePreset } from '$/utils/discord/rolePresets' import { and, eq } from 'drizzle-orm' -import type { Command } from '../types' -export default { - data: new SlashCommandBuilder() - .setName('unmute') - .setDescription('Unmute a member') - .addUserOption(option => option.setName('member').setRequired(true).setDescription('The member to unmute')) - .toJSON(), - - memberRequirements: { - roles: config.moderation?.roles ?? [], +export default new ModerationCommand({ + name: 'unmute', + description: 'Unmute a member', + options: { + member: { + description: 'The member to unmute', + required: true, + type: ModerationCommand.OptionType.User, + }, }, - - global: false, - - async execute({ logger, database }, interaction) { - const user = interaction.options.getUser('member', true) + async execute({ logger, database, executor }, interaction, { member: user }) { const member = await interaction.guild!.members.fetch(user.id) if (!member) throw new CommandError( @@ -39,8 +32,8 @@ export default { throw new CommandError(CommandErrorType.Generic, 'This user is not muted.') await removeRolePreset(member, 'mute') - await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unmuted', user, interaction.user)) + await sendModerationReplyAndLogs(interaction, createModerationActionEmbed('Unmuted', user, executor.user)) - logger.info(`Moderator ${interaction.user.tag} (${interaction.user.id}) unmuted ${user.tag} (${user.id})`) + logger.info(`Moderator ${executor.user.tag} (${executor.id}) unmuted ${user.tag} (${user.id})`) }, -} satisfies Command +}) diff --git a/bots/discord/src/commands/types.ts b/bots/discord/src/commands/types.ts deleted file mode 100644 index ceb232f..0000000 --- a/bots/discord/src/commands/types.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { SlashCommandBuilder } from '@discordjs/builders' -import type { ChatInputCommandInteraction } from 'discord.js' - -// Temporary system -export type Command = { - data: ReturnType - // The function has to return void or Promise - // because TS may complain about some code paths not returning a value - /** - * The function to execute when this command is triggered - * @param interaction The interaction that triggered this command - */ - execute: ( - context: typeof import('../context'), - interaction: ChatInputCommandInteraction, - info: Info, - ) => Promise | void - memberRequirements?: { - /** - * The mode to use when checking for requirements. - * - `all` means that the user needs meet all requirements specified. - * - `any` means that the user needs to meet any of the requirements specified. - * - * @default "all" - */ - mode?: 'all' | 'any' - /** - * The permissions required to use this command (in BitFields). - * - * - **0n** means that everyone can use this command. - * - **-1n** means that only bot owners can use this command. - * @default -1n - */ - permissions?: bigint - /** - * The roles required to use this command. - * By default, this is set to `[]`. - */ - roles?: string[] - } - /** - * Whether this command can only be used by bot admins. - * @default false - */ - adminOnly?: boolean - /** - * Whether to register this command as a global slash command. - * This is set to `false` and commands will be registered in allowed guilds only by default. - * @default false - */ - global?: boolean -} - -export interface Info { - isExecutorBotAdmin: boolean -} diff --git a/bots/discord/src/context.ts b/bots/discord/src/context.ts index 2644af6..a52e71f 100644 --- a/bots/discord/src/context.ts +++ b/bots/discord/src/context.ts @@ -6,19 +6,19 @@ import { createLogger } from '@revanced/bot-shared' import { Client as DiscordClient, Partials } from 'discord.js' import { drizzle } from 'drizzle-orm/bun-sqlite' -// Export config first, as commands require them +// Export some things first, as commands require them import config from '../config.js' export { config } -import * as commands from './commands' -import * as schemas from './database/schemas' - -import type { Command } from './commands/types' - export const logger = createLogger({ level: config.logLevel === 'none' ? Number.MAX_SAFE_INTEGER : config.logLevel, }) +import * as commands from './commands' +import * as schemas from './database/schemas' + +import type { default as Command, CommandOptionsOptions } from './classes/Command' + export const api = { client: new APIClient({ api: { @@ -81,8 +81,8 @@ export const discord = { }, partials: [Partials.Message, Partials.Reaction], }), - commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.data.name, cmd])) as Record< + commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record< string, - Command + Command >, } as const diff --git a/bots/discord/src/events/discord/interactionCreate/chatCommand.ts b/bots/discord/src/events/discord/interactionCreate/chatCommand.ts index b608dd8..28c7f56 100644 --- a/bots/discord/src/events/discord/interactionCreate/chatCommand.ts +++ b/bots/discord/src/events/discord/interactionCreate/chatCommand.ts @@ -1,78 +1,22 @@ import CommandError from '$/classes/CommandError' -import { isAdmin } from '$/utils/discord/permissions' -import { createErrorEmbed, createStackTraceEmbed } from '$utils/discord/embeds' +import { createStackTraceEmbed } from '$utils/discord/embeds' import { on, withContext } from '$utils/discord/events' withContext(on, 'interactionCreate', async (context, interaction) => { if (!interaction.isChatInputCommand()) return - const { logger, discord, config } = context + const { logger, discord } = context const command = discord.commands[interaction.commandName] logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`) if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) - const isExecutorBotAdmin = isAdmin(await interaction.guild?.members.fetch(interaction.user.id) || interaction.user, config.admin) - - /** - * Admin check - */ - if (command.adminOnly && !isExecutorBotAdmin) - return void (await interaction.reply({ - embeds: [createErrorEmbed('Massive skill issue', 'This command can only be used by the bot admins.')], - ephemeral: true, - })) - - /** - * Sanity check - */ - if (!command.global && !interaction.inGuild()) { - logger.error(`Command ${interaction.commandName} cannot be run in DMs, but was registered as global`) - await interaction.reply({ - embeds: [createErrorEmbed('Cannot run that here', 'This command can only be used in a server.')], - ephemeral: true, - }) - return - } - - /** - * Permission checks - */ - if (interaction.inGuild()) { - // Bot owners get bypass - if (command.memberRequirements && !isExecutorBotAdmin) { - const { permissions = 0n, roles = [], mode } = command.memberRequirements - - const member = await interaction.guild!.members.fetch(interaction.user.id) - - const [missingPermissions, missingRoles] = [ - // This command is an owner-only command (the user is not an owner, checked above) - permissions <= 0n || - // or the user doesn't have the required permissions - (permissions > 0n && !interaction.memberPermissions.has(permissions)), - - // If not: - !roles.some(x => member.roles.cache.has(x)), - ] - - if ((mode === 'any' && missingPermissions && missingRoles) || missingPermissions || missingRoles) - return void interaction.reply({ - embeds: [ - createErrorEmbed( - 'Missing roles or permissions', - "You don't have the required roles or permissions to use this command.", - ), - ], - ephemeral: true, - }) - } - } - try { logger.debug(`Command ${interaction.commandName} being executed`) - await command.execute(context, interaction, { isExecutorBotAdmin }) + await command.onInteraction(context, interaction) } catch (err) { - logger.error(`Error while executing command ${interaction.commandName}:`, 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 new file mode 100644 index 0000000..2b2ecd4 --- /dev/null +++ b/bots/discord/src/events/discord/messageCreate/messageCommand.ts @@ -0,0 +1,53 @@ +import { type CommandArguments, CommandSpecialArgumentType } from '$/classes/Command' +import CommandError from '$/classes/CommandError' +import { createStackTraceEmbed } from '$utils/discord/embeds' +import { on, withContext } from '$utils/discord/events' + +withContext(on, 'messageCreate', async (context, msg) => { + const { logger, discord, config } = context + + if (msg.author.bot) return + + const regex = new RegExp(`^(?:${config.prefix}|${msg.client.user.toString()}\\s*)([a-zA-Z-_]+)(?:\\s+)?(.+)?`) + const matches = msg.content.match(regex) + + if (!matches) return + const [, commandName, argsString] = matches + if (!commandName) return + + const command = discord.commands[commandName] + logger.debug(`Command ${commandName} being invoked by ${msg.author.id}`) + if (!command) return void logger.error(`Command ${commandName} not implemented`) + + const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g + const args: CommandArguments = [] + let match: RegExpExecArray | null + + // biome-ignore lint/suspicious/noAssignInExpressions: nuh uh + while ((match = argsRegex.exec(argsString ?? '')) !== null) { + const arg = match[1] ? match[1] : match[0] + const mentionMatch = arg.match(/<(@(?:!|&)?|#)(.+?)>/) + + if (mentionMatch) { + const [, prefix, id] = mentionMatch + + if (!id || !prefix) { + args.push('') + continue + } + + args.push({ + type: CommandSpecialArgumentType[prefix[1] === '&' ? 'Role' : prefix[0] === '#' ? 'Channel' : 'User'], + id, + }) + } else args.push(arg) + } + + try { + logger.debug(`Command ${commandName} being executed`) + await command.onMessage(context, msg, args) + } catch (err) { + if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err) + await msg.reply({ embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)] }) + } +}) diff --git a/bots/discord/src/events/discord/messageCreate/scanMessage.ts b/bots/discord/src/events/discord/messageCreate/scanMessage.ts index 1009808..848253b 100644 --- a/bots/discord/src/events/discord/messageCreate/scanMessage.ts +++ b/bots/discord/src/events/discord/messageCreate/scanMessage.ts @@ -13,7 +13,7 @@ withContext(on, 'messageCreate', async (context, msg) => { } = context if (!config || !config.responses) return - if (msg.author.bot && !config.scanBots) + if (msg.author.bot && !config.scanBots) return if (!msg.inGuild() && !config.scanOutsideGuilds) return if (msg.inGuild() && msg.member?.partial) await msg.member.fetch() @@ -24,7 +24,11 @@ withContext(on, 'messageCreate', async (context, msg) => { try { logger.debug(`Classifying message ${msg.id}`) - const { response, label, replyToReplied } = await getResponseFromText(msg.content, filteredResponses, context) + const { response, label, replyToReplied } = await getResponseFromText( + msg.content, + filteredResponses, + context, + ) if (response) { logger.debug('Response found') diff --git a/bots/discord/src/events/discord/messageReactionAdd/correctResponse.ts b/bots/discord/src/events/discord/messageReactionAdd/correctResponse.ts index 4aa8a30..4e73609 100644 --- a/bots/discord/src/events/discord/messageReactionAdd/correctResponse.ts +++ b/bots/discord/src/events/discord/messageReactionAdd/correctResponse.ts @@ -13,8 +13,8 @@ import { import type { ConfigMessageScanResponseLabelConfig } from '$/../config.schema' import { responses } from '$/database/schemas' import { handleUserResponseCorrection } from '$/utils/discord/messageScan' -import { eq } from 'drizzle-orm' import { isAdmin } from '$/utils/discord/permissions' +import { eq } from 'drizzle-orm' const PossibleReactions = Object.values(Reactions) as string[] diff --git a/bots/discord/src/events/discord/ready.ts b/bots/discord/src/events/discord/ready.ts index 73e8742..e40af8a 100644 --- a/bots/discord/src/events/discord/ready.ts +++ b/bots/discord/src/events/discord/ready.ts @@ -7,9 +7,7 @@ import { on, withContext } from 'src/utils/discord/events' export default withContext(on, 'ready', ({ config, logger }, client) => { logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`) - logger.info( - `Bot is in ${client.guilds.cache.size} guilds, if this is not expected, please run the /leave-unknowns command`, - ) + logger.info(`Bot is in ${client.guilds.cache.size} guilds`) if (config.rolePresets) { removeExpiredPresets(client) diff --git a/bots/discord/src/utils/discord/messageScan.ts b/bots/discord/src/utils/discord/messageScan.ts index 5352901..8c285bd 100644 --- a/bots/discord/src/utils/discord/messageScan.ts +++ b/bots/discord/src/utils/discord/messageScan.ts @@ -1,9 +1,5 @@ import { type Response, responses } from '$/database/schemas' -import type { - Config, - ConfigMessageScanResponse, - ConfigMessageScanResponseLabelConfig -} from 'config.schema' +import type { Config, ConfigMessageScanResponse, ConfigMessageScanResponseLabelConfig } from 'config.schema' import type { Message, PartialUser, User } from 'discord.js' import { eq } from 'drizzle-orm' import { createMessageScanResponseEmbed } from './embeds' @@ -17,7 +13,7 @@ export const getResponseFromText = async ( ): Promise => { let responseConfig: Awaited> = { triggers: {}, - response: null + response: null, } const firstLabelIndexes: number[] = [] @@ -29,7 +25,7 @@ export const getResponseFromText = async ( // Filter override check is not neccessary here, we are already passing responses that match the filter // from the messageCreate handler, see line 17 of messageCreate handler const { - triggers: { text: textTriggers, image: imageTriggers } + triggers: { text: textTriggers, image: imageTriggers }, } = trigger if (responseConfig) break @@ -92,7 +88,7 @@ export const getResponseFromText = async ( logger.debug('No match from NLP, doing after regexes') for (let i = 0; i < responses.length; i++) { const { - triggers: { text: textTriggers } + triggers: { text: textTriggers }, } = responses[i]! const firstLabelIndex = firstLabelIndexes[i] ?? -1 @@ -113,10 +109,7 @@ export const getResponseFromText = async ( return responseConfig } -export const messageMatchesFilter = ( - message: Message, - filter: NonNullable['filter'], -) => { +export const messageMatchesFilter = (message: Message, filter: NonNullable['filter']) => { if (!filter) return true const memberRoles = new Set(message.member?.roles.cache.keys()) @@ -124,7 +117,12 @@ export const messageMatchesFilter = ( // If matches blacklist, will return false // Any other case, will return true - return !(blFilter && (blFilter.channels?.includes(message.channelId) || blFilter.roles?.some(role => memberRoles.has(role)) || blFilter.users?.includes(message.author.id))) + return !( + blFilter && + (blFilter.channels?.includes(message.channelId) || + blFilter.roles?.some(role => memberRoles.has(role)) || + blFilter.users?.includes(message.author.id)) + ) } export const handleUserResponseCorrection = async ( diff --git a/bots/discord/src/utils/discord/moderation.ts b/bots/discord/src/utils/discord/moderation.ts index 0250676..38d3953 100644 --- a/bots/discord/src/utils/discord/moderation.ts +++ b/bots/discord/src/utils/discord/moderation.ts @@ -1,6 +1,6 @@ import { config, logger } from '$/context' import decancer from 'decancer' -import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, User } from 'discord.js' +import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js' import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds' const PresetLogAction = { @@ -10,19 +10,23 @@ const PresetLogAction = { export const sendPresetReplyAndLogs = ( action: keyof typeof PresetLogAction, - interaction: ChatInputCommandInteraction, + interaction: ChatInputCommandInteraction | Message, + executor: GuildMember, user: User, preset: string, expires?: number | null, ) => sendModerationReplyAndLogs( interaction, - createModerationActionEmbed(PresetLogAction[action], user, interaction.user, undefined, expires, [ + createModerationActionEmbed(PresetLogAction[action], user, executor.user, undefined, expires, [ [{ name: 'Preset', value: preset, inline: true }], ]), ) -export const sendModerationReplyAndLogs = async (interaction: ChatInputCommandInteraction, embed: EmbedBuilder) => { +export const sendModerationReplyAndLogs = async ( + interaction: ChatInputCommandInteraction | Message, + embed: EmbedBuilder, +) => { const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch()) const logChannel = await getLogChannel(interaction.guild!) await logChannel?.send({ embeds: [applyReferenceToModerationActionEmbed(embed, reply.url)] }) @@ -46,7 +50,7 @@ export const getLogChannel = async (guild: Guild) => { } export const cureNickname = async (member: GuildMember) => { - if (!member.manageable) throw new Error('Member is not manageable') + if (!member.manageable) return const name = member.displayName let cured = decancer(name) .toString() diff --git a/bots/discord/src/utils/discord/permissions.ts b/bots/discord/src/utils/discord/permissions.ts index 576e4c7..1e97854 100644 --- a/bots/discord/src/utils/discord/permissions.ts +++ b/bots/discord/src/utils/discord/permissions.ts @@ -2,10 +2,13 @@ import { GuildMember, type User } from 'discord.js' import config from '../../../config' export const isAdmin = (userOrMember: User | GuildMember) => { - return config.admin?.users?.includes(userOrMember.id) || (userOrMember instanceof GuildMember && isMemberAdmin(userOrMember)) + return ( + config.admin?.users?.includes(userOrMember.id) || + (userOrMember instanceof GuildMember && isMemberAdmin(userOrMember)) + ) } export const isMemberAdmin = (member: GuildMember) => { const roles = new Set(member.roles.cache.keys()) return Boolean(config?.admin?.roles?.[member.guild.id]?.some(role => roles.has(role))) -} \ No newline at end of file +} diff --git a/bots/discord/src/utils/discord/rolePresets.ts b/bots/discord/src/utils/discord/rolePresets.ts index 925d436..69c2fa5 100644 --- a/bots/discord/src/utils/discord/rolePresets.ts +++ b/bots/discord/src/utils/discord/rolePresets.ts @@ -6,9 +6,9 @@ import { and, eq } from 'drizzle-orm' // TODO: Fix this type type PresetKey = string -export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number | null) => { +export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number) => { const afterInsert = await applyRolesUsingPreset(presetName, member, true) - const until = untilMs ? Math.ceil(untilMs / 1000) : null + const until = untilMs === Infinity ? null : Math.ceil(untilMs / 1000) await database .insert(appliedPresets) diff --git a/bots/discord/src/utils/fs.ts b/bots/discord/src/utils/fs.ts index 0c91b2d..b42f5fc 100644 --- a/bots/discord/src/utils/fs.ts +++ b/bots/discord/src/utils/fs.ts @@ -7,17 +7,27 @@ export const listAllFilesRecursive = (dir: string): string[] => .filter(x => x.isFile()) .map(x => join(x.parentPath, x.name).replaceAll(pathSep, posixPathSep)) -export const generateCommandsIndex = (dirPath: string) => generateIndexes(dirPath, x => !x.endsWith('types.ts')) +export const generateCommandsIndex = (dirPath: string) => + generateIndexes(dirPath, (x, i) => `export { default as C${i} } from './${x}'`) export const generateEventsIndex = (dirPath: string) => generateIndexes(dirPath) -const generateIndexes = async (dirPath: string, pathFilter?: (path: string) => boolean) => { +const generateIndexes = async ( + dirPath: string, + customMap?: (path: string, index: number) => string, + pathFilter?: (path: string) => boolean, +) => { const files = listAllFilesRecursive(dirPath) - .filter(x => (x.endsWith('.ts') && !x.endsWith('index.ts') && pathFilter ? pathFilter(x) : true)) + .filter(x => x.endsWith('.ts') && !x.endsWith('index.ts') && (pathFilter ? pathFilter(x) : true)) .map(x => relative(dirPath, x).replaceAll(pathSep, posixPathSep)) writeFileSync( join(dirPath, 'index.ts'), - `// AUTO-GENERATED BY A SCRIPT, DON'T TOUCH\n\n${files.map(c => `import './${c.split('.').at(-2)}'`).join('\n')}`, + `// AUTO-GENERATED BY A SCRIPT, DON'T TOUCH\n\n${files + .map((c, i) => { + const path = c.split('.').at(-2)! + return customMap ? customMap(path, i) : `import './${path}'` + }) + .join('\n')}`, ) }