import { type Client, type ChatInputCommandInteraction, type AutocompleteInteraction, type ButtonInteraction, type AnySelectMenuInteraction, type ModalSubmitInteraction, type APIApplicationCommandOption, type APIApplicationCommandBasicOption, type CommandInteraction, type UserContextMenuCommandInteraction, type MessageContextMenuCommandInteraction, BaseInteraction, type RESTPostAPIApplicationCommandsJSONBody, ApplicationCommandType, } from "discord.js"; import { HaltExecution } from "./errors"; import { type Option, type InferOptions, OPTION_TYPES } from "./options"; import { KittenComponent, type KittenComponentInteraction } from "./components"; import { Command, CommandBuilder, SubcommandGroupBuilder, ContextMenuCommand, type IntegrationType, type InteractionContextType, } from "./commands"; import { baseLogger, type KittenLogger } from "./logger"; export interface KittenOptions { logger?: KittenLogger; } export class Kitten { private commands = new Map>(); private userContextMenus = new Map(); private messageContextMenus = new Map(); private components = new Map>(); private logger: KittenLogger; constructor( private client: Client, options: KittenOptions = {}, ) { this.client.on("interactionCreate", this.handleInteraction.bind(this)); this.logger = options.logger ?? baseLogger; } builder() { return new CommandBuilder(); } command>>( name: string, config: { description: string; options?: O; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; run: ( interaction: ChatInputCommandInteraction, args: InferOptions, ) => Promise | void; }, ): Command; command( name: string, config: { description: string; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; }, ): SubcommandGroupBuilder<{}>; command(name: string, config: any): any { return this.builder().command(name, config); } userContextMenu( name: string, runOrConfig: | ((interaction: UserContextMenuCommandInteraction) => Promise | void) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; run: (interaction: UserContextMenuCommandInteraction) => Promise | void; }, ): ContextMenuCommand { return this.builder().userContextMenu(name, runOrConfig as any); } messageContextMenu( name: string, runOrConfig: | ((interaction: MessageContextMenuCommandInteraction) => Promise | void) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; run: (interaction: MessageContextMenuCommandInteraction) => Promise | void; }, ): ContextMenuCommand { return this.builder().messageContextMenu(name, runOrConfig as any); } button>>( name: string, config: { options?: O; run: (interaction: ButtonInteraction, args: InferOptions) => Promise | void; }, ): KittenComponent { return new KittenComponent(name, config); } selectMenu>>( name: string, config: { options?: O; run: (interaction: AnySelectMenuInteraction, args: InferOptions) => Promise | void; }, ): KittenComponent { return new KittenComponent(name, config); } modal>>( name: string, config: { options?: O; run: (interaction: ModalSubmitInteraction, args: InferOptions) => Promise | void; }, ): KittenComponent { return new KittenComponent(name, config); } register(registry: { commands?: (Command | SubcommandGroupBuilder | ContextMenuCommand)[]; components?: KittenComponent[]; }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) { if (cmd.type === "user") this.userContextMenus.set(cmd.name, cmd); else if (cmd.type === "message") this.messageContextMenus.set(cmd.name, cmd); } else { this.commands.set(cmd.name, cmd); } }); registry.components?.forEach((comp) => this.components.set(comp.name, comp)); } private async handleInteraction(interaction: BaseInteraction) { switch (true) { // commands case interaction.isChatInputCommand(): return await this.routeCommand(interaction); case interaction.isAutocomplete(): return await this.routeAutocomplete(interaction); case interaction.isUserContextMenuCommand(): return await this.routeContextMenu(interaction, "user"); case interaction.isMessageContextMenuCommand(): return await this.routeContextMenu(interaction, "message"); // components case interaction.isButton(): return await this.routeComponent(interaction, "button"); case interaction.isAnySelectMenu(): return await this.routeComponent(interaction, "select menu"); case interaction.isModalSubmit(): return await this.routeComponent(interaction, "modal"); } } private async routeCommand(interaction: ChatInputCommandInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) return; if (entry instanceof SubcommandGroupBuilder) { const groupName = interaction.options.getSubcommandGroup(false); const subName = interaction.options.getSubcommand(false); if (groupName) { const group = entry.groups[groupName]; if (!group) return; const subcommand = group.subcommands[subName ?? ""]; if (!subcommand) return; await this.executeWithMiddlewares( interaction, entry.middlewares, subcommand.config.run, subcommand.config.options, ); } else if (subName) { const subcommand = entry.subcommands[subName]; if (!subcommand) return; await this.executeWithMiddlewares( interaction, entry.middlewares, subcommand.config.run, subcommand.config.options, ); } } else { await this.executeWithMiddlewares( interaction, entry.middlewares, entry.config.run, entry.config.options, ); } } private async routeContextMenu( interaction: UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction, type: "user" | "message", ) { const entry = type === "user" ? this.userContextMenus.get(interaction.commandName) : this.messageContextMenus.get(interaction.commandName); if (!entry) return; await this.executeWithMiddlewares(interaction, entry.middlewares, entry.run); } private async executeWithMiddlewares( interaction: CommandInteraction, middlewares: Function[], run: Function, optionsSchema?: Record>, ) { try { let context = {}; for (const mw of middlewares) { const result = await mw(interaction, context); if (result) context = { ...context, ...result }; } if (interaction.isChatInputCommand()) { const args = this.parseSlashOptions(interaction, optionsSchema); await run(interaction, args, context); } else { await run(interaction, context); } } catch (err) { if (err instanceof HaltExecution) return; this.logger.error(`unhandled error executing command ${interaction.commandName}:`, { error: err instanceof Error ? err.stack : String(err), command: interaction.commandName, user: interaction.user.id, }); if (!interaction.replied && !interaction.deferred) { await interaction .reply({ content: "an error occurred while executing this command.", ephemeral: true }) .catch(() => null); } } } private async routeAutocomplete(interaction: AutocompleteInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) return; const focused = interaction.options.getFocused(true); let optionsSchema: Record> | undefined; if (entry instanceof SubcommandGroupBuilder) { const groupName = interaction.options.getSubcommandGroup(false); const subName = interaction.options.getSubcommand(false); if (groupName && subName) optionsSchema = entry.groups[groupName]?.subcommands[subName]?.config.options; else if (subName) optionsSchema = entry.subcommands[subName]?.config.options; } else { optionsSchema = entry.config.options; } const opt = optionsSchema?.[focused.name]; if (opt?.autocomplete) { const choices = await opt.autocomplete(interaction, focused.value); await interaction.respond(choices).catch(() => null); } } private async routeComponent(interaction: KittenComponentInteraction, typeName: string) { const [prefix] = interaction.customId.split(":"); if (!prefix) return; const component = this.components.get(prefix); if (!component) return; try { const args = component.parseArgs(interaction.customId); await component.config.run(interaction, args); } catch (err) { this.logger.error(`error executing ${typeName} ${prefix}:`, { error: err instanceof Error ? err.stack : String(err), component: prefix, user: interaction.user.id, }); } } private parseSlashOptions( interaction: ChatInputCommandInteraction, optionsSchema?: Record>, ): Record { if (!optionsSchema) return {}; const parsed: Record = {}; for (const [name, opt] of Object.entries(optionsSchema)) { switch (opt.type) { case "string": parsed[name] = interaction.options.getString(name); break; case "integer": parsed[name] = interaction.options.getInteger(name); break; case "number": parsed[name] = interaction.options.getNumber(name); break; case "boolean": parsed[name] = interaction.options.getBoolean(name); break; case "user": parsed[name] = interaction.options.getUser(name); break; case "channel": parsed[name] = interaction.options.getChannel(name); break; case "role": parsed[name] = interaction.options.getRole(name); break; case "mentionable": parsed[name] = interaction.options.getMentionable(name); break; case "attachment": parsed[name] = interaction.options.getAttachment(name); break; } } return parsed; } async sync({ guildId }: { guildId?: string } = {}) { const payloads: RESTPostAPIApplicationCommandsJSONBody[] = []; // chat input commands for (const cmd of this.commands.values()) { if (cmd instanceof SubcommandGroupBuilder) { const subcommandOptions: APIApplicationCommandOption[] = Object.values(cmd.subcommands).map((sub) => ({ type: OPTION_TYPES.subcommand, name: sub.name, description: sub.config.description, options: this.transformOptionsForDiscord(sub.config.options), })); const groupOptions: APIApplicationCommandOption[] = Object.values(cmd.groups).map((group) => ({ type: OPTION_TYPES.subcommandGroup, name: group.name, description: group.description, options: Object.values(group.subcommands).map((sub) => ({ type: OPTION_TYPES.subcommand, name: sub.name, description: sub.config.description, options: this.transformOptionsForDiscord(sub.config.options), })), })); const payload: RESTPostAPIApplicationCommandsJSONBody = { type: ApplicationCommandType.ChatInput, name: cmd.name, description: cmd.description, options: [...subcommandOptions, ...groupOptions], }; if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; payloads.push(payload); } else { const payload: RESTPostAPIApplicationCommandsJSONBody = { type: ApplicationCommandType.ChatInput, name: cmd.name, description: cmd.config.description, options: this.transformOptionsForDiscord(cmd.config.options), }; if (cmd.config.integrationTypes) payload.integration_types = cmd.config.integrationTypes; if (cmd.config.contexts) payload.contexts = cmd.config.contexts; payloads.push(payload); } } // context menus for (const cmd of this.userContextMenus.values()) { const payload: RESTPostAPIApplicationCommandsJSONBody = { type: ApplicationCommandType.User, name: cmd.name, }; if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; payloads.push(payload); } for (const cmd of this.messageContextMenus.values()) { const payload: RESTPostAPIApplicationCommandsJSONBody = { type: ApplicationCommandType.Message, name: cmd.name, }; if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; payloads.push(payload); } if (!this.client.application) { throw new Error("client application not ready to sync. ensure you await client.login()"); } if (guildId) { await this.client.application.commands.set(payloads, guildId); this.logger.info(`synced ${payloads.length} commands to guild ${guildId}.`, { guildId, commandCount: payloads.length, }); } else { await this.client.application.commands.set(payloads); this.logger.info(`synced ${payloads.length} global commands.`, { commandCount: payloads.length, }); } } private transformOptionsForDiscord( options?: Record>, ): APIApplicationCommandBasicOption[] { if (!options) return []; return Object.entries(options).map(([name, opt]) => { const payload: any = { name, description: opt.description, type: OPTION_TYPES[opt.type], required: opt.required, }; if (opt.autocomplete) { payload.autocomplete = true; } return payload as APIApplicationCommandBasicOption; }); } }