import { type Client, type ChatInputCommandInteraction, type AutocompleteInteraction, type ButtonInteraction, type ApplicationCommandNonOptionsData, BaseInteraction, } from "discord.js"; import { HaltExecution } from "./errors"; import { type Option, type InferOptions, OPTION_TYPES } from "./options"; import { KittenComponent } from "./components"; import { Command, CommandBuilder, SubcommandGroupBuilder } from "./commands"; import { baseLogger, type KittenLogger } from "./logger"; export interface KittenOptions { logger?: KittenLogger; } export class Kitten { private commands = 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; run: ( interaction: ChatInputCommandInteraction, args: InferOptions, ) => Promise | void; }, ): Command; command(name: string, config: { description: string }): SubcommandGroupBuilder<{}>; command(name: string, config: any): any { return this.builder().command(name, config); } button>>( name: string, config: { options?: O; run: (interaction: ButtonInteraction, args: InferOptions) => Promise | void; }, ): KittenComponent { return new KittenComponent(name, config); } register(registry: { commands?: (Command | SubcommandGroupBuilder)[]; components?: KittenComponent[]; }) { registry.commands?.forEach((cmd) => this.commands.set(cmd.name, cmd)); registry.components?.forEach((comp) => this.components.set(comp.name, comp)); } private async handleInteraction(interaction: BaseInteraction) { // commands if (interaction.isChatInputCommand()) await this.routeCommand(interaction); else if (interaction.isAutocomplete()) await this.routeAutocomplete(interaction); // components else if (interaction.isButton()) await this.routeButton(interaction); } 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 executeWithMiddlewares( interaction: ChatInputCommandInteraction, 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 }; } const args = this.parseSlashOptions(interaction, optionsSchema); await run(interaction, args, context); } catch (err) { // TODO)) custom error handlers 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 routeButton(interaction: ButtonInteraction) { 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 button ${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 = Array.from(this.commands.values()).map((cmd) => { if (cmd instanceof SubcommandGroupBuilder) { const subcommandOptions = 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 = 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), })), })); return { name: cmd.name, description: cmd.description, options: [...subcommandOptions, ...groupOptions], }; } else { return { name: cmd.name, description: cmd.config.description, options: this.transformOptionsForDiscord(cmd.config.options), }; } }); 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>, ): ApplicationCommandNonOptionsData[] { 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 ApplicationCommandNonOptionsData; }); } }