import { EventEmitter } from "node:events"; import { type Client, type ChatInputCommandInteraction, type AutocompleteInteraction, type ButtonInteraction, type AnySelectMenuInteraction, type ModalSubmitInteraction, type APIApplicationCommandOption, type APIApplicationCommandBasicOption, type UserContextMenuCommandInteraction, type MessageContextMenuCommandInteraction, BaseInteraction, type RESTPostAPIApplicationCommandsJSONBody, ApplicationCommandType, PermissionsBitField, } from "discord.js"; import { HaltExecution } from "./errors"; import { type Option, type CommandOption, OPTION_TYPES } from "./options"; import { KittenComponent, type KittenComponentConfig, type KittenComponentInteraction, } from "./components"; import { Command, CommandBuilder, Subcommand, SubcommandGroupBuilder, ContextMenuCommand, type BaseCommandConfig, type LocalizedDescription, type ChatCommandConfig, type ContextMenuConfig, type CommandResultType, type Middleware, type ErrorHandler, type Afterware, } from "./commands"; export interface KittenOptions { /** a global fallback error handler. */ onError?: ErrorHandler; } export interface KittenEvents { /** diagnostics and routing traces. */ debug: [message: string, data?: Record]; /** command registration and API sync notices. */ info: [message: string, data?: Record]; /** non-fatal runtime warning telemetry. */ warn: [message: string, data?: Record]; /** fallback system telemetry for unhandled execution failures. */ error: [message: string, data?: Record]; } /** * the main entry point for routing your bot. * * @see {@link KittenEvents} for telemetry events you can subscribe to. */ export class Kitten extends EventEmitter { private commands = new Map | SubcommandGroupBuilder>(); private userContextMenus = new Map>(); private messageContextMenus = new Map>(); private components = new Map>(); private globalOnError?: ErrorHandler; constructor( private client: Client, options: KittenOptions = {}, ) { super(); this.client.on("interactionCreate", this.handleInteraction.bind(this)); this.globalOnError = options.onError; process.nextTick(() => { this.emit("debug", "kitten instance initialized."); }); } /** * instantiate a fresh `CommandBuilder` to start chaining middleware and afterware. */ builder() { return new CommandBuilder(); } /** * register a global error handler. this acts as the final safety net if an exception * bubbles all the way up through your local and builder scopes. */ onError(handler: ErrorHandler) { this.globalOnError = handler; return this; } /** * define a chat input command attached directly to the global instance. */ command>>( name: string, config: ChatCommandConfig, ): Command<{}>; /** * define a subcommand group attached directly to the global instance. */ command( name: string, config: BaseCommandConfig & LocalizedDescription, ): SubcommandGroupBuilder<{}>; command(name: string, config: any): any { return this.builder().command(name, config); } /** * define a user context menu attached directly to the global instance. */ userContextMenu( name: string, runOrConfig: | ((interaction: UserContextMenuCommandInteraction) => CommandResultType) | ContextMenuConfig, ): ContextMenuCommand<{}, UserContextMenuCommandInteraction> { return this.builder().userContextMenu(name, runOrConfig as any); } /** * define a message context menu attached directly to the global instance. */ messageContextMenu( name: string, runOrConfig: | ((interaction: MessageContextMenuCommandInteraction) => CommandResultType) | ContextMenuConfig, ): ContextMenuCommand<{}, MessageContextMenuCommandInteraction> { return this.builder().messageContextMenu(name, runOrConfig as any); } /** * define a button component attached directly to the global instance. */ button>>( name: string, config: KittenComponentConfig, ): KittenComponent { return this.builder().button(name, config); } /** * define a select menu component attached directly to the global instance. */ selectMenu>>( name: string, config: KittenComponentConfig, ): KittenComponent { return this.builder().selectMenu(name, config); } /** * define a modal component attached directly to the global instance. */ modal>>( name: string, config: KittenComponentConfig, ): KittenComponent { return this.builder().modal(name, config); } /** * tell kitten about your commands and components so it can route them. * ensure you do this *before* calling `kitten.sync()`. */ register(registry: { commands?: ( | Command | SubcommandGroupBuilder | ContextMenuCommand )[]; components?: KittenComponent[]; }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) { const targetMap = cmd.type === "user" ? this.userContextMenus : this.messageContextMenus; targetMap.set(cmd.name, cmd); this.emit("debug", `registered ${cmd.type} context menu: "${cmd.name}"`); } else { this.commands.set(cmd.name, cmd); const subInfo = cmd instanceof SubcommandGroupBuilder ? " (subcommand group)" : ""; this.emit("debug", `registered command: "${cmd.name}"${subInfo}`); } }); registry.components?.forEach((comp) => { this.components.set(comp.name, comp); const keys = Object.keys(comp.config.options || {}); const staticOverhead = comp.name.length + keys.length; const dynamicBudget = 100 - staticOverhead; if (dynamicBudget < 25) { this.emit( "warn", [ `component "${comp.name}" has a high static ID overhead (${staticOverhead} chars).`, `This leaves only ${dynamicBudget} characters for actual runtime values before`, `hitting Discord's 100-character limit.`, ].join(" "), ); } else { this.emit("debug", `registered component prefix: "${comp.name}"`); } }); } private async handleInteraction(interaction: BaseInteraction) { this.emit("debug", `received interaction ID: ${interaction.id}, Type: ${interaction.type}`); if (interaction.isChatInputCommand()) return this.routeCommand(interaction); if (interaction.isAutocomplete()) return this.routeAutocomplete(interaction); if (interaction.isUserContextMenuCommand()) return this.routeContextMenu(interaction, "user"); if (interaction.isMessageContextMenuCommand()) return this.routeContextMenu(interaction, "message"); if (interaction.isButton() || interaction.isAnySelectMenu() || interaction.isModalSubmit()) { return this.routeComponent(interaction); } this.emit("debug", `interaction ID: ${interaction.id} could not be classified or handled.`); } private async routeCommand(interaction: ChatInputCommandInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) { this.emit( "debug", `command routing aborted: "${interaction.commandName}" is not registered.`, ); return; } if (entry instanceof SubcommandGroupBuilder) { const groupName = interaction.options.getSubcommandGroup(false); const subName = interaction.options.getSubcommand(false); this.emit( "debug", `routing group-based command: "${interaction.commandName}", group: ${groupName ?? "none"}, subcommand: ${subName ?? "none"}`, ); let subcommand: Subcommand | undefined; if (groupName) { const group = entry.groups[groupName]; if (!group) return this.emit("debug", `subcommand group "${groupName}" not found.`); subcommand = group.subcommands[subName ?? ""]; if (!subcommand) return this.emit("debug", `subcommand "${subName}" not found in group "${groupName}".`); } else if (subName) { subcommand = entry.subcommands[subName]; if (!subcommand) return this.emit("debug", `subcommand "${subName}" not found.`); } if (subcommand) { await this.executeWithMiddlewares( interaction, entry.middlewares, subcommand.config.run, subcommand.config.options, undefined, { localOnError: subcommand.config.onError, builderErrorHandlers: entry.errorHandlers, afterwares: entry.afterwares, }, ); } } else { this.emit("debug", `routing top-level chat command: "${interaction.commandName}"`); await this.executeWithMiddlewares( interaction, entry.middlewares, entry.config.run, entry.config.options, undefined, { localOnError: entry.config.onError, builderErrorHandlers: entry.errorHandlers, afterwares: entry.afterwares, }, ); } } 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) { this.emit( "debug", `context menu routing aborted: "${interaction.commandName}" (${type}) is not registered.`, ); return; } this.emit("debug", `routing context menu command: "${interaction.commandName}" (${type})`); await this.executeWithMiddlewares( interaction, entry.middlewares, entry.run, undefined, undefined, { localOnError: entry.config?.onError, builderErrorHandlers: entry.errorHandlers, afterwares: entry.afterwares, }, ); } private async executeWithMiddlewares( interaction: BaseInteraction, middlewares: Middleware[], run: Function, optionsSchema?: Record>, componentArgs?: Record, extra?: { localOnError?: ErrorHandler; builderErrorHandlers?: ErrorHandler[]; afterwares?: Afterware[]; }, ) { let context: Record = {}; let executionResult: unknown = undefined; let executionError: unknown = null; let didStartExecution = false; try { this.emit( "debug", `Running ${middlewares.length} middleware(s) for interaction ${interaction.id}`, ); didStartExecution = true; for (let i = 0; i < middlewares.length; i++) { const mw = middlewares[i]; if (!mw) continue; this.emit("debug", `executing middleware [${i + 1}/${middlewares.length}]`); const result = await mw(interaction, context); if (result) context = { ...context, ...result }; } if (interaction.isChatInputCommand()) { const args = this.parseSlashOptions(interaction, optionsSchema); this.emit("debug", `executing command handler for "${interaction.commandName}"`, { args }); executionResult = await run(interaction, args, context); } else if ( interaction.isButton() || interaction.isAnySelectMenu() || interaction.isModalSubmit() ) { const customId = (interaction as any).customId; this.emit("debug", `executing component handler for custom ID: "${customId}"`, { componentArgs, }); executionResult = await run(interaction, componentArgs ?? {}, context); } else { this.emit("debug", `executing generic fallback handler for interaction ${interaction.id}`); executionResult = await run(interaction, context); } } catch (err) { executionError = err; if (err instanceof HaltExecution) { this.emit("debug", `execution halted intentionally for interaction ${interaction.id}.`); if ( err.replyPayload && interaction.isRepliable() && !interaction.replied && !interaction.deferred ) { const replyOptions = typeof err.replyPayload === "string" ? { content: err.replyPayload, ephemeral: true } : err.replyPayload; await interaction.reply(replyOptions).catch(() => null); } return; } let activeError = err; let handled = false; const handleErr = async (handler?: ErrorHandler) => { if (!handler) return false; try { this.emit("debug", `executing error handler for interaction ${interaction.id}`); await handler(activeError, interaction, context); return true; } catch (e) { this.emit("debug", `error handler threw an error, propagating...`); activeError = e; return false; } }; if (await handleErr(extra?.localOnError)) handled = true; if (!handled && extra?.builderErrorHandlers) { for (const handler of extra.builderErrorHandlers) { if (await handleErr(handler)) { handled = true; break; } } } if (!handled) handled = await handleErr(this.globalOnError); if (!handled) { const isCommand = "commandName" in interaction; const identifier = isCommand ? (interaction as any).commandName : "customId" in interaction ? (interaction as any).customId : "unknown"; const typeLabel = isCommand ? "command" : "component"; this.emit("error", `unhandled error executing ${typeLabel} ${identifier}:`, { error: activeError instanceof Error ? activeError.stack : String(activeError), [typeLabel]: identifier, user: interaction.user.id, }); if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { await interaction .reply({ content: `An error occurred while executing this ${typeLabel}.`, flags: ["Ephemeral"], }) .catch(() => null); } } } finally { if (didStartExecution && extra?.afterwares?.length) { this.emit( "debug", `Running ${extra.afterwares.length} afterware(s) for interaction ${interaction.id}`, ); for (let i = 0; i < extra.afterwares.length; i++) { const aw = extra.afterwares[i]; if (!aw) continue; try { this.emit("debug", `executing afterware [${i + 1}/${extra.afterwares.length}]`); await aw(interaction, context, executionError, executionResult); } catch (awErr) { this.emit( "error", `unhandled error executing afterware [${i + 1}/${extra.afterwares.length}]:`, { error: awErr instanceof Error ? awErr.stack : String(awErr), interaction: interaction.id, }, ); } } } } } private async routeAutocomplete(interaction: AutocompleteInteraction) { const entry = this.commands.get(interaction.commandName); if (!entry) { this.emit( "debug", `autocomplete aborted: command "${interaction.commandName}" not registered.`, ); 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) { this.emit( "debug", `handling autocomplete for command "${interaction.commandName}" option "${focused.name}" with value "${focused.value}"`, ); const choices = await opt.autocomplete(interaction, focused.value); await interaction.respond(choices).catch(() => null); } else { this.emit( "debug", `no autocomplete handler configured for option "${focused.name}" in command "${interaction.commandName}"`, ); } } private async routeComponent(interaction: KittenComponentInteraction) { const [prefix] = interaction.customId.split(":"); if (!prefix) { this.emit( "debug", `component routing skipped: unable to extract prefix from customId "${interaction.customId}".`, ); return; } const component = this.components.get(prefix); if (!component) { this.emit( "debug", `component routing skipped: no component handler registered for prefix "${prefix}".`, ); return; } this.emit( "debug", `routing component for prefix "${prefix}" (customId:${interaction.customId})`, ); const args = component.parseArgs(interaction.customId); await this.executeWithMiddlewares( interaction, component.middlewares, component.config.run, undefined, args, { localOnError: component.config.onError, builderErrorHandlers: component.errorHandlers, afterwares: component.afterwares, }, ); } private parseSlashOptions( interaction: ChatInputCommandInteraction, optionsSchema?: Record>, ): Record { if (!optionsSchema) return {}; const parsed: Record = {}; this.emit("debug", `parsing options for command "${interaction.commandName}"`); const typeToMethod = { string: "getString", integer: "getInteger", number: "getNumber", boolean: "getBoolean", user: "getUser", channel: "getChannel", role: "getRole", mentionable: "getMentionable", attachment: "getAttachment", } as const; for (const [name, opt] of Object.entries(optionsSchema)) { const method = typeToMethod[opt.type as keyof typeof typeToMethod]; if (method) { parsed[name] = (interaction.options as any)[method](name); } else { this.emit("debug", `skipping option "${name}" due to unmapped option type: "${opt.type}"`); } } return parsed; } private applyBaseAppCommandConfig(payload: any, config: any) { if (!config) return payload; if (config.nameLocalizations) payload.name_localizations = config.nameLocalizations; if (config.descriptionLocalizations) payload.description_localizations = config.descriptionLocalizations; if (config.integrationTypes) payload.integration_types = config.integrationTypes; if (config.contexts) payload.contexts = config.contexts; if (config.defaultMemberPermissions !== undefined) { payload.default_member_permissions = config.defaultMemberPermissions === null ? null : new PermissionsBitField(config.defaultMemberPermissions).bitfield.toString(); } if (config.nsfw !== undefined) payload.nsfw = config.nsfw; return payload; } private mapSubcommand(sub: Subcommand): APIApplicationCommandOption { return { type: OPTION_TYPES.subcommand, name: sub.name, name_localizations: sub.config.nameLocalizations, description: sub.config.description, description_localizations: sub.config.descriptionLocalizations, options: this.transformOptionsForDiscord(sub.config.options), } as APIApplicationCommandOption; } /** * syncs your registered commands and context menus with the discord API. * * @remarks * it is recommended to pass `{ guildId: "..." }` during active development, as * discord updates guild commands instantly, while global syncs can take up to an hour. */ async sync({ guildId }: { guildId?: string } = {}) { const payloads: RESTPostAPIApplicationCommandsJSONBody[] = []; // chat input commands for (const cmd of this.commands.values()) { const payload: any = { type: ApplicationCommandType.ChatInput, name: cmd.name, description: cmd instanceof SubcommandGroupBuilder ? cmd.description : cmd.config.description, }; if (cmd instanceof SubcommandGroupBuilder) { payload.options = [ ...Object.values(cmd.subcommands).map((sub) => this.mapSubcommand(sub)), ...Object.values(cmd.groups).map((group) => ({ type: OPTION_TYPES.subcommandGroup, name: group.name, name_localizations: group.config?.nameLocalizations, description: group.description, description_localizations: group.config?.descriptionLocalizations, options: Object.values(group.subcommands).map((sub) => this.mapSubcommand(sub)), })), ]; this.applyBaseAppCommandConfig(payload, cmd.config); } else { payload.options = this.transformOptionsForDiscord(cmd.config.options); this.applyBaseAppCommandConfig(payload, cmd.config); } payloads.push(payload); } // context menus for (const cmd of this.userContextMenus.values()) { payloads.push( this.applyBaseAppCommandConfig( { type: ApplicationCommandType.User, name: cmd.name, }, cmd.config, ), ); } for (const cmd of this.messageContextMenus.values()) { payloads.push( this.applyBaseAppCommandConfig( { type: ApplicationCommandType.Message, name: cmd.name, }, cmd.config, ), ); } if (!this.client.application) { throw new Error("client application not ready to sync. ensure you await client.login()"); } this.emit("debug", `preparing to sync ${payloads.length} command(s) to Discord API...`); if (guildId) { await this.client.application.commands.set(payloads, guildId); this.emit("info", `synced ${payloads.length} commands to guild ${guildId}.`, { guildId, commandCount: payloads.length, }); } else { await this.client.application.commands.set(payloads); this.emit("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; if (opt.choices) { payload.choices = opt.choices.map((c) => ({ name: c.name, name_localizations: c.nameLocalizations, value: c.value, })); } if (opt.nameLocalizations) payload.name_localizations = opt.nameLocalizations; if (opt.descriptionLocalizations) payload.description_localizations = opt.descriptionLocalizations; return payload as APIApplicationCommandBasicOption; }); } }