diff --git a/apps/test-bot/src/app/commands/(developer)/server.ts b/apps/test-bot/src/app/commands/(developer)/server.ts index fe790c5c..6f6be21e 100644 --- a/apps/test-bot/src/app/commands/(developer)/server.ts +++ b/apps/test-bot/src/app/commands/(developer)/server.ts @@ -3,13 +3,17 @@ import { type ChatInputCommand, type MessageCommand, Logger, + CommandMetadata, } from 'commandkit'; export const command: CommandData = { name: 'server', description: 'server command', - guilds: [process.env.DEV_GUILD_ID!], +}; + +export const metadata: CommandMetadata = { aliases: ['s', 'serv'], + guilds: [process.env.DEV_GUILD_ID!], }; export const chatInput: ChatInputCommand = async (ctx) => { diff --git a/apps/test-bot/src/app/commands/(general)/componentsTest.tsx b/apps/test-bot/src/app/commands/(general)/componentsTest.tsx index 7f6cec56..eccc6837 100644 --- a/apps/test-bot/src/app/commands/(general)/componentsTest.tsx +++ b/apps/test-bot/src/app/commands/(general)/componentsTest.tsx @@ -1,6 +1,7 @@ import { ChatInputCommand, CommandData, + CommandMetadataFunction, Container, MessageCommand, TextDisplay, @@ -10,7 +11,12 @@ import { Colors, MessageFlags } from 'discord.js'; export const command: CommandData = { name: 'components-test', description: 'Test components v2 again', - aliases: ['ct'], +}; + +export const generateMetadata: CommandMetadataFunction = () => { + return { + aliases: ['ct'], + }; }; export const chatInput: ChatInputCommand = async (ctx) => { diff --git a/apps/test-bot/src/app/commands/(general)/gallery.tsx b/apps/test-bot/src/app/commands/(general)/gallery.tsx index 3041dff0..ca7cb197 100644 --- a/apps/test-bot/src/app/commands/(general)/gallery.tsx +++ b/apps/test-bot/src/app/commands/(general)/gallery.tsx @@ -1,6 +1,7 @@ import { ChatInputCommand, CommandData, + CommandMetadata, MediaGallery, MediaGalleryItem, TextDisplay, @@ -12,6 +13,11 @@ export const command: CommandData = { description: 'Test components v2 gallery', }; +export const metadata: CommandMetadata = { + botPermissions: ['BanMembers'], + userPermissions: ['Administrator'], +}; + const mediaItems: string[] = Array.from( { length: 6, diff --git a/apps/test-bot/src/events/messageCreate/01-log.ts b/apps/test-bot/src/events/messageCreate/01-log.ts index 2b9dbc72..9cfa7479 100644 --- a/apps/test-bot/src/events/messageCreate/01-log.ts +++ b/apps/test-bot/src/events/messageCreate/01-log.ts @@ -3,6 +3,7 @@ import { stopEvents, isErrorType, CommandKitErrorCodes, + Logger, } from 'commandkit'; const handler: EventHandler<'messageCreate'> = (message) => { @@ -12,15 +13,15 @@ const handler: EventHandler<'messageCreate'> = (message) => { stopEvents(); // conditionally stop the event chain } catch (error) { if (isErrorType(error, CommandKitErrorCodes.StopEvents)) { - console.log('Stopping event chain'); + Logger.log('Stopping event chain'); // if stopEvents() is called in the try block, throw it so CommandKit can stop the event chain throw error; } - console.log('Not stopping event chain'); + Logger.log('Not stopping event chain'); // this means that the code threw the error, and stopEvents() was not called // the rest of the event handlers will be executed as normal - console.error(error); + Logger.error(error); } }; diff --git a/apps/test-bot/src/events/messageCreate/02-log.ts b/apps/test-bot/src/events/messageCreate/02-log.ts index c8bb9677..8a4eae06 100644 --- a/apps/test-bot/src/events/messageCreate/02-log.ts +++ b/apps/test-bot/src/events/messageCreate/02-log.ts @@ -3,6 +3,7 @@ import { stopEvents, isErrorType, CommandKitErrorCodes, + Logger, } from 'commandkit'; const handler: EventHandler<'messageCreate'> = (message) => { @@ -14,15 +15,15 @@ const handler: EventHandler<'messageCreate'> = (message) => { // stopEvents(); // conditionally stop the event chain } catch (error) { if (isErrorType(error, CommandKitErrorCodes.StopEvents)) { - console.log('Stopping event chain'); + Logger.log('Stopping event chain'); // if stopEvents() is called in the try block, throw it so CommandKit can stop the event chain throw error; } - console.log('Not stopping event chain'); + Logger.log('Not stopping event chain'); // this means that the code threw the error, and stopEvents() was not called // the rest of the event handlers will be executed as normal - console.error(error); + Logger.error(error); } }; diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index 620067e9..2e17211d 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -1,5 +1,4 @@ import { - ApplicationCommandType, AutocompleteInteraction, Awaitable, Collection, @@ -13,7 +12,11 @@ import { import type { CommandKit } from '../../commandkit'; import { AsyncFunction, GenericFunction } from '../../context/async-context'; import { Logger } from '../../logger/Logger'; -import type { CommandData } from '../../types'; +import type { + CommandData, + CommandMetadata, + CommandMetadataFunction, +} from '../../types'; import colors from '../../utils/colors'; import { COMMANDKIT_IS_DEV } from '../../utils/constants'; import { CommandKitErrorCodes, isErrorType } from '../../utils/error-codes'; @@ -25,6 +28,14 @@ import { MessageCommandParser } from '../commands/MessageCommandParser'; import { CommandRegistrar } from '../register/CommandRegistrar'; import { Command, Middleware } from '../router'; import { getConfig } from '../../config/config'; +import { beforeExecute, middlewareId } from '../middlewares/permissions'; + +const KNOWN_NON_HANDLER_KEYS = [ + 'command', + 'generateMetadata', + 'metadata', + 'aiConfig', +]; /** * Function type for wrapping command execution with custom logic. @@ -38,6 +49,8 @@ export type RunCommand = (fn: T) => T; */ export interface AppCommandNative { command: CommandData | Record; + generateMetadata?: CommandMetadataFunction; + metadata?: CommandMetadata; chatInput?: (ctx: Context) => Awaitable; autocomplete?: (ctx: Context) => Awaitable; message?: (ctx: Context) => Awaitable; @@ -73,8 +86,8 @@ interface AppCommandMiddleware { */ export interface LoadedCommand { command: Command; + metadata: CommandMetadata; data: AppCommand; - guilds?: string[]; } /** @@ -129,8 +142,22 @@ const commandDataSchema = { userContextMenu: (c: unknown) => typeof c === 'function', }; +/** + * @private + * @internal + */ export type CommandDataSchema = typeof commandDataSchema; + +/** + * @private + * @internal + */ export type CommandDataSchemaKey = keyof CommandDataSchema; + +/** + * @private + * @internal + */ export type CommandDataSchemaValue = CommandDataSchema[CommandDataSchemaKey]; /** @@ -444,8 +471,8 @@ export class AppCommandHandler { if ( source.guildId && - loadedCommand.guilds?.length && - !loadedCommand.guilds.includes(source.guildId!) + loadedCommand.metadata?.guilds?.length && + !loadedCommand.metadata?.guilds.includes(source.guildId!) ) { return null; } @@ -499,8 +526,8 @@ export class AppCommandHandler { (source instanceof CommandInteraction || source instanceof AutocompleteInteraction) && source.guildId && - loadedCommand.guilds?.length && - !loadedCommand.guilds.includes(source.guildId) + loadedCommand.metadata?.guilds?.length && + !loadedCommand.metadata?.guilds.includes(source.guildId) ) { return null; } @@ -516,6 +543,24 @@ export class AppCommandHandler { } } + if (!getConfig().disablePermissionsMiddleware) { + middlewares.push({ + data: { + // @ts-ignore + beforeExecute, + }, + middleware: { + command: null, + global: true, + id: middlewareId, + name: 'permissions', + parentPath: '', + path: '', + relativePath: '', + }, + }); + } + // No middleware for subcommands since they inherit from parent command return { command: loadedCommand, @@ -536,7 +581,7 @@ export class AppCommandHandler { } // Check aliases for prefix commands - const aliases = loadedCommand.data.command.aliases; + const aliases = loadedCommand.data.metadata?.aliases; if (aliases && Array.isArray(aliases) && aliases.includes(name)) { return loadedCommand; } @@ -640,7 +685,7 @@ export class AppCommandHandler { (v) => v.data.command.name, ); const aliases = Array.from(this.loadedCommands.values()).flatMap( - (v) => v.data.command.aliases || [], + (v) => v.metadata.aliases || [], ); const allNames = [...commandNames, ...aliases]; @@ -698,6 +743,12 @@ export class AppCommandHandler { if (command.path === null) { this.loadedCommands.set(id, { command, + metadata: { + guilds: [], + aliases: [], + userPermissions: [], + botPermissions: [], + }, data: { command: { name: command.name, @@ -719,6 +770,22 @@ export class AppCommandHandler { ); } + const metadataFunc = commandFileData.generateMetadata; + const metadataObj = commandFileData.metadata; + + if (metadataFunc && metadataObj) { + throw new Error( + 'A command may only export either `generateMetadata` or `metadata`, not both', + ); + } + + const metadata = (metadataFunc ? await metadataFunc() : metadataObj) ?? { + aliases: [], + guilds: [], + userPermissions: [], + botPermissions: [], + }; + // Apply the specified logic for name and description const commandName = commandFileData.command.name || command.name; const commandDescription = @@ -729,7 +796,6 @@ export class AppCommandHandler { ...commandFileData.command, name: commandName, description: commandDescription, - aliases: commandFileData.command.aliases, } as CommandData; let handlerCount = 0; @@ -747,7 +813,7 @@ export class AppCommandHandler { ); } - if (key !== 'command') { + if (!KNOWN_NON_HANDLER_KEYS.includes(key)) { // command file includes a handler function (chatInput, message, etc) handlerCount++; } @@ -770,19 +836,53 @@ export class AppCommandHandler { } }); + const commandJson = + 'toJSON' in lastUpdated && typeof lastUpdated.toJSON === 'function' + ? lastUpdated.toJSON() + : lastUpdated; + + if ('guilds' in commandJson || 'aliases' in commandJson) { + Logger.warn( + `Command \`${command.name}\` uses deprecated metadata properties. Please update to use the new \`metadata\` object or \`generateMetadata\` function.`, + ); + } + this.loadedCommands.set(id, { command, - guilds: commandFileData.command.guilds, + metadata: { + guilds: commandJson.guilds, + aliases: commandJson.aliases, + ...metadata, + }, data: { ...commandFileData, - command: - 'toJSON' in lastUpdated && typeof lastUpdated.toJSON === 'function' - ? lastUpdated.toJSON() - : lastUpdated, + metadata: { + guilds: commandJson.guilds, + aliases: commandJson.aliases, + ...metadata, + }, + command: commandJson, }, }); } catch (error) { Logger.error(`Failed to load command ${command.name} (${id})`, error); } } + + /** + * Gets the metadata for a command. + * @param command - The command name to get metadata for + * @returns The command metadata or null if not found + */ + public getMetadataFor(command: string): CommandMetadata | null { + const loadedCommand = this.findCommandByName(command); + if (!loadedCommand) return null; + + return (loadedCommand.metadata ??= { + aliases: [], + guilds: [], + userPermissions: [], + botPermissions: [], + }); + } } diff --git a/packages/commandkit/src/app/middlewares/permissions.ts b/packages/commandkit/src/app/middlewares/permissions.ts new file mode 100644 index 00000000..a3cc9972 --- /dev/null +++ b/packages/commandkit/src/app/middlewares/permissions.ts @@ -0,0 +1,128 @@ +import { + EmbedBuilder, + PermissionFlags, + PermissionFlagsBits, + PermissionResolvable, +} from 'discord.js'; +import { MiddlewareContext } from '../commands/Context'; +import { getConfig } from '../../config/config'; + +const findName = (flags: PermissionFlags, flag: PermissionResolvable) => { + if (typeof flag === 'string') return flag; + + return ( + Object.entries(flags).find(([_, value]) => value === flag)?.[0] ?? `${flag}` + ); +}; + +export const middlewareId = crypto.randomUUID(); + +/** + * @private + * @ignore + */ +export async function beforeExecute(ctx: MiddlewareContext) { + if (getConfig().disablePermissionsMiddleware) return; + + const { interaction, command } = ctx; + + if (interaction.isAutocomplete()) return; + const userPermissions = interaction.memberPermissions; + let userPermissionsRequired = command.metadata?.userPermissions; + let missingUserPermissions: string[] = []; + + if (typeof userPermissionsRequired === 'string') { + userPermissionsRequired = [userPermissionsRequired]; + } + + const botPermissions = interaction.guild?.members.me?.permissions; + let botPermissionsRequired = command.metadata?.botPermissions; + let missingBotPermissions: string[] = []; + + if (typeof botPermissionsRequired === 'string') { + botPermissionsRequired = [botPermissionsRequired]; + } + + if (!userPermissionsRequired?.length && !botPermissionsRequired?.length) { + return; + } + + if (userPermissions && userPermissionsRequired) { + for (const permission of userPermissionsRequired) { + const hasPermission = userPermissions.has(permission); + + if (!hasPermission) { + missingUserPermissions.push( + typeof permission === 'string' + ? permission + : findName(PermissionFlagsBits, permission), + ); + } + } + } + + if (botPermissions && botPermissionsRequired) { + for (const permission of botPermissionsRequired) { + const hasPermission = botPermissions.has(permission); + + if (!hasPermission) { + missingBotPermissions.push( + typeof permission === 'string' + ? permission + : findName(PermissionFlagsBits, permission), + ); + } + } + } + + if (!missingUserPermissions.length && !missingBotPermissions.length) { + return; + } + + // Fix casing. e.g. KickMembers -> Kick Members + const pattern = /([a-z])([A-Z])|([A-Z]+)([A-Z][a-z])/g; + + missingUserPermissions = missingUserPermissions.map((str) => + str.replace(pattern, '$1$3 $2$4'), + ); + missingBotPermissions = missingBotPermissions.map((str) => + str.replace(pattern, '$1$3 $2$4'), + ); + + let embedDescription = ''; + + // @ts-ignore + const formatter = new Intl.ListFormat('en', { + style: 'long', + type: 'conjunction', + }); + + const getPermissionWord = (permissions: string[]) => + permissions.length === 1 ? 'permission' : 'permissions'; + + if (missingUserPermissions.length) { + const formattedPermissions = missingUserPermissions.map((p) => `\`${p}\``); + const permissionsString = formatter.format(formattedPermissions); + + embedDescription += `- You must have the ${permissionsString} ${getPermissionWord( + missingUserPermissions, + )} to be able to run this command.\n`; + } + + if (missingBotPermissions.length) { + const formattedPermissions = missingBotPermissions.map((p) => `\`${p}\``); + const permissionsString = formatter.format(formattedPermissions); + + embedDescription += `- I must have the ${permissionsString} ${getPermissionWord( + missingBotPermissions, + )} to be able to execute this command.\n`; + } + + const embed = new EmbedBuilder() + .setTitle(`:x: Missing permissions!`) + .setDescription(embedDescription) + .setColor('Red'); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return true; +} diff --git a/packages/commandkit/src/app/register/CommandRegistrar.ts b/packages/commandkit/src/app/register/CommandRegistrar.ts index 2ed10562..10349ce0 100644 --- a/packages/commandkit/src/app/register/CommandRegistrar.ts +++ b/packages/commandkit/src/app/register/CommandRegistrar.ts @@ -1,6 +1,6 @@ import { ApplicationCommandType, REST, Routes } from 'discord.js'; import { CommandKit } from '../../commandkit'; -import { CommandData } from '../../types'; +import { CommandData, CommandMetadata } from '../../types'; import { Logger } from '../../logger/Logger'; /** @@ -37,7 +37,7 @@ export class CommandRegistrar { /** * Gets the commands data. */ - public getCommandsData(): CommandData[] { + public getCommandsData(): (CommandData & { __metadata?: CommandMetadata })[] { const handler = this.commandkit.commandHandler; // Use the public method instead of accessing private property const commands = handler.getCommandsArray(); @@ -48,7 +48,14 @@ export class CommandRegistrar { ? cmd.data.command.toJSON() : cmd.data.command; - const collections: CommandData[] = [json]; + const __metadata = cmd.metadata ?? cmd.data.metadata; + + const collections: (CommandData & { __metadata?: CommandMetadata })[] = [ + { + ...json, + __metadata, + }, + ]; // Handle context menu commands if ( @@ -62,6 +69,8 @@ export class CommandRegistrar { description_localizations: undefined, // @ts-ignore description: undefined, + // @ts-ignore + __metadata, }); } @@ -76,6 +85,7 @@ export class CommandRegistrar { // @ts-ignore description: undefined, options: undefined, + __metadata, }); } @@ -112,14 +122,14 @@ export class CommandRegistrar { } const guildCommands = commands - .filter((command) => command.guilds?.filter(Boolean).length) + .filter((command) => command.__metadata?.guilds?.filter(Boolean).length) .map((c) => ({ ...c, - guilds: Array.from(new Set(c.guilds?.filter(Boolean))), + guilds: Array.from(new Set(c.__metadata?.guilds?.filter(Boolean))), })); const globalCommands = commands.filter( - (command) => !command.guilds?.filter(Boolean).length, + (command) => !command.__metadata?.guilds?.filter(Boolean).length, ); await this.updateGlobalCommands(globalCommands); @@ -129,7 +139,9 @@ export class CommandRegistrar { /** * Updates the global commands. */ - public async updateGlobalCommands(commands: CommandData[]) { + public async updateGlobalCommands( + commands: (CommandData & { __metadata?: CommandMetadata })[], + ) { if (!commands.length) return; let prevented = false; @@ -151,7 +163,7 @@ export class CommandRegistrar { { body: commands.map((c) => ({ ...c, - guilds: undefined, + __metadata: undefined, })), }, )) as CommandData[]; @@ -167,7 +179,9 @@ export class CommandRegistrar { /** * Updates the guild commands. */ - public async updateGuildCommands(commands: CommandData[]) { + public async updateGuildCommands( + commands: (CommandData & { __metadata?: CommandMetadata })[], + ) { if (!commands.length) return; let prevented = false; @@ -190,9 +204,9 @@ export class CommandRegistrar { const guildCommandsMap = new Map(); commands.forEach((command) => { - if (!command.guilds?.length) return; + if (!command.__metadata?.guilds?.length) return; - command.guilds.forEach((guild) => { + command.__metadata?.guilds?.forEach((guild) => { if (!guildCommandsMap.has(guild)) { guildCommandsMap.set(guild, []); } @@ -229,7 +243,7 @@ export class CommandRegistrar { { body: guildCommands.map((b) => ({ ...b, - guilds: undefined, + __metadata: undefined, })), }, )) as CommandData[]; diff --git a/packages/commandkit/src/config/config.ts b/packages/commandkit/src/config/config.ts index a3937a99..35113877 100644 --- a/packages/commandkit/src/config/config.ts +++ b/packages/commandkit/src/config/config.ts @@ -74,6 +74,9 @@ export function defineConfig( ...defaultConfig.antiCrashScript, ...config.antiCrashScript, }, + disablePermissionsMiddleware: + config.disablePermissionsMiddleware ?? + defaultConfig.disablePermissionsMiddleware, }; return defined; diff --git a/packages/commandkit/src/config/default.ts b/packages/commandkit/src/config/default.ts index a4b4ff3c..4ee9d480 100644 --- a/packages/commandkit/src/config/default.ts +++ b/packages/commandkit/src/config/default.ts @@ -28,6 +28,7 @@ export const defaultConfig: ResolvedCommandKitConfig = { }, typedCommands: true, disablePrefixCommands: false, + disablePermissionsMiddleware: false, showUnknownPrefixCommandsWarning: true, antiCrashScript: { development: true, diff --git a/packages/commandkit/src/config/types.ts b/packages/commandkit/src/config/types.ts index 156da75e..a9be1d1d 100644 --- a/packages/commandkit/src/config/types.ts +++ b/packages/commandkit/src/config/types.ts @@ -104,6 +104,11 @@ export interface CommandKitConfig { * @default false */ disablePrefixCommands?: boolean; + /** + * Whether or not to disable the built-in permissions middleware. This only affects `botPermissions` and `userPermissions` in the command metadata. + * @default false + */ + disablePermissionsMiddleware?: boolean; /** * Whether or not to show a warning when a prefix command is not found. This only affects development mode. * @default true diff --git a/packages/commandkit/src/types.ts b/packages/commandkit/src/types.ts index a2f610f9..44981591 100644 --- a/packages/commandkit/src/types.ts +++ b/packages/commandkit/src/types.ts @@ -3,6 +3,7 @@ import type { Client, ClientEvents, Interaction, + PermissionResolvable, RESTPostAPIApplicationCommandsJSONBody, } from 'discord.js'; import type { CommandKit } from './commandkit'; @@ -42,15 +43,54 @@ export interface CommandContext< handler: CommandKit; } +/** + * Represents the command metadata. + */ +export interface CommandMetadata { + /** + * The guilds that the command is available in. + */ + guilds?: string[]; + /** + * The aliases of the command. + */ + aliases?: string[]; + /** + * The user permissions required to execute the command. + */ + userPermissions?: PermissionResolvable[]; + /** + * The bot permissions required to execute the command. + */ + botPermissions?: PermissionResolvable[]; +} + +/** + * @deprecated Use `CommandMetadata` instead. + */ +export interface LegacyCommandMetadata { + /** + * The aliases of the command. + * @deprecated Use `metadata` or `generateMetadata` instead. + */ + aliases?: string[]; + /** + * The guilds that the command is available in. + * @deprecated Use `metadata` or `generateMetadata` instead. + */ + guilds?: string[]; +} + /** * Represents a command that can be executed by CommandKit. */ export type CommandData = Prettify< Omit & { + /** + * The description of the command. + */ description?: string; - guilds?: string[]; - aliases?: string[]; - } + } & LegacyCommandMetadata >; /** @@ -59,3 +99,10 @@ export type CommandData = Prettify< export type EventHandler = ( ...args: [...ClientEvents[K], Client, CommandKit] ) => void | Promise; + +/** + * The command metadata function + */ +export type CommandMetadataFunction = () => + | Promise + | CommandMetadata; diff --git a/packages/legacy/src/plugin.ts b/packages/legacy/src/plugin.ts index 3a401296..1ec13e00 100644 --- a/packages/legacy/src/plugin.ts +++ b/packages/legacy/src/plugin.ts @@ -206,7 +206,9 @@ export class LegacyHandlerPlugin extends RuntimePlugin