diff --git a/pkgs/router/changelogs/1.8.0.md b/pkgs/router/changelogs/1.8.0.md new file mode 100644 index 0000000..c4ebc2a --- /dev/null +++ b/pkgs/router/changelogs/1.8.0.md @@ -0,0 +1,23 @@ +# [1.8.0] 2026-07-09 + +this release is focused on devex, internal refactorring and code cleanliness. +i've shed quite a few lines of code and i believe improved the overall readability +of the codebase. + +i've replaced the massive, deeply-nested inline configuration types across the +builder methods with cleanly exported interfaces like `ChatCommandConfig`, +`ContextMenuConfig`, and `KittenComponentConfig`. + +i also added a few JSDoc comments to a lot of public methods and etc! +you'll now see helpful descriptions, strict type hints, and usage examples +directly in your editor for: + +- routing definitions like `Middleware`, `ErrorHandler`, and `Afterware`, +- command, component, and option builders, and +- intentional control-flow exceptions like `HaltExecution` and `CustomIdTooLong`. + +internally, i refactored the API synchronization payload generators +(`kitten.sync()`), error propagation trees, option parsing, and interaction +routing handlers to significantly reduce code duplication. + +there are no new features or changes to the public-facing API. diff --git a/pkgs/router/package.json b/pkgs/router/package.json index fc6723c..35ec68e 100644 --- a/pkgs/router/package.json +++ b/pkgs/router/package.json @@ -1,6 +1,6 @@ { "name": "@purrkit/router", - "version": "1.7.0", + "version": "1.8.0", "license": "EUPL-1.2", "author": { "name": "apr", diff --git a/pkgs/router/src/commands.ts b/pkgs/router/src/commands.ts index 4d44b5f..253f0b4 100644 --- a/pkgs/router/src/commands.ts +++ b/pkgs/router/src/commands.ts @@ -9,7 +9,11 @@ import type { LocalizationMap, } from "discord.js"; import type { CommandOption, Option, InferOptions } from "./options"; -import { KittenComponent } from "./components"; +import { + KittenComponent, + KittenComponentInteraction, + type KittenComponentConfig, +} from "./components"; export const INTEGRATION_TYPES = { GUILD_INSTALL: 0, @@ -37,17 +41,34 @@ export type IntegrationType = (typeof INTEGRATION_TYPES)[keyof typeof INTEGRATIO export type InteractionContextType = (typeof INTERACTION_CONTEXTS)[keyof typeof INTERACTION_CONTEXTS]; +/** + * pre-execution interceptors that let you chain execution checks and pass their + * results down as strongly-typed context. + * + * @throws {HaltExecution} if you wish to intentionally stop propagation. + */ export type Middleware = ( interaction: I, ctx: InCtx, ) => Promise | OutCtx | void; +/** + * an exception handler that can catch, handle, or rethrow errors. + * these are context-aware and will receive whatever context was successfully accumulated + * before the exception occurred. + */ export type ErrorHandler = ( err: unknown, interaction: I, ctx: Ctx, ) => Promise | unknown; +/** + * strictly-typed post-execution flow handlers. + * + * afterwares run sequentially inside a safe `finally` block and execute regardless + * of whether the interaction completed cleanly, threw an exception, or was halted. + */ export type Afterware = ( interaction: I, ctx: Ctx, @@ -55,17 +76,46 @@ export type Afterware Promise | void; +export interface BaseCommandConfig { + integrationTypes?: IntegrationType[]; + contexts?: InteractionContextType[]; + nameLocalizations?: LocalizationMap; + defaultMemberPermissions?: string | number | bigint | any; + nsfw?: boolean; + onError?: ErrorHandler; +} + +export interface LocalizedDescription { + description: string; + descriptionLocalizations?: LocalizationMap; +} + +export interface ChatCommandConfig< + O extends Record>, + Ctx = any, + Res = unknown, +> + extends BaseCommandConfig, LocalizedDescription { + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions, + ctx: Ctx, + ) => CommandResultType; +} + +export interface ContextMenuConfig< + I extends BaseInteraction, + Ctx = any, + Res = unknown, +> extends BaseCommandConfig { + run: (interaction: I, ctx: Ctx) => CommandResultType; +} + export class Subcommand { constructor( public name: string, - public config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: Record>; - onError?: ErrorHandler; - run: Function; - }, + public config: ChatCommandConfig, ) {} } @@ -81,24 +131,12 @@ export class SubcommandGroup { }, ) {} + /** + * append a subcommand to this specific group. + */ subcommand>, R extends Res>( name: string, - config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: O; - onError?: ( - err: unknown, - interaction: ChatInputCommandInteraction, - ctx: Ctx, - ) => Promise | unknown; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: ChatCommandConfig, ): this { this.subcommands[name] = new Subcommand(name, config); return this; @@ -115,46 +153,26 @@ export class SubcommandGroupBuilder { public middlewares: Middleware[] = [], public errorHandlers: ErrorHandler[] = [], public afterwares: Afterware[] = [], - public config?: { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - }, + public config?: BaseCommandConfig & LocalizedDescription, ) {} + /** + * chain a loose subcommand onto this builder. + */ subcommand>, R extends Res>( name: string, - config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: O; - onError?: ( - err: unknown, - interaction: ChatInputCommandInteraction, - ctx: Ctx, - ) => Promise | unknown; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: ChatCommandConfig, ): this { this.subcommands[name] = new Subcommand(name, config); return this; } + /** + * define a sub-group to isolate subcommands further. + */ group( name: string, - config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, + config: LocalizedDescription & { nameLocalizations?: LocalizationMap }, setup: (group: SubcommandGroup) => void, ): this { const group = new SubcommandGroup(name, config.description, config); @@ -167,22 +185,7 @@ export class SubcommandGroupBuilder { export class Command { constructor( public name: string, - public config: { - description: string; - options?: Record>; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: ( - interaction: ChatInputCommandInteraction, - args: any, - ctx: Ctx, - ) => CommandResultType; - }, + public config: ChatCommandConfig, public middlewares: Middleware[] = [], public errorHandlers: ErrorHandler[] = [], public afterwares: Afterware[] = [], @@ -201,17 +204,14 @@ export class ContextMenuCommand< public middlewares: Middleware[] = [], public errorHandlers: ErrorHandler[] = [], public afterwares: Afterware[] = [], - public config?: { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - }, + public config?: BaseCommandConfig, ) {} } +/** + * a chainable builder for creating commands, context menus, and components with shared + * context, middleware, error handlers, and afterwares. + */ export class CommandBuilder { constructor( private middlewares: Middleware[] = [], @@ -219,6 +219,10 @@ export class CommandBuilder { private afterwares: Afterware[] = [], ) {} + /** + * attach a middleware to this builder. any context returned will be merged and + * strictly typed for all subsequent middlewares and handlers. + */ use>( mw: (interaction: BaseInteraction, ctx: Ctx) => Promise | NewCtx | void, ): CommandBuilder { @@ -229,6 +233,10 @@ export class CommandBuilder { ); } + /** + * attach a builder-level error handler. if an exception is thrown inside a command + * or component produced by this builder, this handler will catch it. + */ onError(handler: ErrorHandler): CommandBuilder { return new CommandBuilder( this.middlewares, @@ -237,6 +245,17 @@ export class CommandBuilder { ); } + /** + * attach an afterware to this builder. these strictly enforce the return type of your commands + * to prevent type safety degradation. + * + * @example + * ```ts + * .after(async (interaction, ctx, err, result) => { + * // result is strictly typed as: MyResult | undefined + * }) + * ``` + */ after(handler: Afterware): CommandBuilder { return new CommandBuilder(this.middlewares, this.errorHandlers, [ ...this.afterwares, @@ -244,37 +263,20 @@ export class CommandBuilder { ]); } + /** + * construct a top-level chat input command. + */ command>, R extends Res>( name: string, - config: { - description: string; - options?: O; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: ChatCommandConfig, ): Command; + /** + * construct a subcommand group base. + */ command( name: string, - config: { - description: string; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - }, + config: BaseCommandConfig & LocalizedDescription, ): SubcommandGroupBuilder; command(name: string, config: any): any { @@ -293,166 +295,91 @@ export class CommandBuilder { this.middlewares, this.errorHandlers, this.afterwares, - { - integrationTypes: config.integrationTypes, - contexts: config.contexts, - nameLocalizations: config.nameLocalizations, - descriptionLocalizations: config.descriptionLocalizations, - defaultMemberPermissions: config.defaultMemberPermissions, - nsfw: config.nsfw, - }, + config, ); } - userContextMenu( - name: string, - runOrConfig: - | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; - }, - ): ContextMenuCommand { - if (typeof runOrConfig === "function") { - return new ContextMenuCommand( - name, - "user", - runOrConfig, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); - } - - return new ContextMenuCommand( + private createContextMenu< + I extends UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction, + R extends Res, + >(name: string, type: "user" | "message", runOrConfig: any) { + const isFn = typeof runOrConfig === "function"; + return new ContextMenuCommand( name, - "user", - runOrConfig.run, + type, + isFn ? runOrConfig : runOrConfig.run, this.middlewares, this.errorHandlers, this.afterwares, - { - integrationTypes: runOrConfig.integrationTypes, - contexts: runOrConfig.contexts, - nameLocalizations: runOrConfig.nameLocalizations, - defaultMemberPermissions: runOrConfig.defaultMemberPermissions, - nsfw: runOrConfig.nsfw, - onError: runOrConfig.onError, - }, + isFn ? undefined : runOrConfig, ); } + /** + * construct a user context menu command. + */ + userContextMenu( + name: string, + runOrConfig: + | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType) + | ContextMenuConfig, + ): ContextMenuCommand { + return this.createContextMenu(name, "user", runOrConfig); + } + + /** + * construct a message context menu command. + */ messageContextMenu( name: string, runOrConfig: | ((interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: ( - interaction: MessageContextMenuCommandInteraction, - ctx: Ctx, - ) => CommandResultType; - }, + | ContextMenuConfig, ): ContextMenuCommand { - if (typeof runOrConfig === "function") { - return new ContextMenuCommand( - name, - "message", - runOrConfig, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); - } + return this.createContextMenu(name, "message", runOrConfig); + } - return new ContextMenuCommand( + private createComponent< + I extends KittenComponentInteraction, + O extends Record>, + R extends Res, + >(name: string, config: any): KittenComponent { + return new KittenComponent( name, - "message", - runOrConfig.run, + config, this.middlewares, this.errorHandlers, this.afterwares, - { - integrationTypes: runOrConfig.integrationTypes, - contexts: runOrConfig.contexts, - nameLocalizations: runOrConfig.nameLocalizations, - defaultMemberPermissions: runOrConfig.defaultMemberPermissions, - nsfw: runOrConfig.nsfw, - onError: runOrConfig.onError, - }, ); } + /** + * construct a reusable button component handler. + */ button>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler; - run: ( - interaction: ButtonInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: KittenComponentConfig, ): KittenComponent { - return new KittenComponent( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } + /** + * construct a reusable select menu component handler. + */ selectMenu>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler; - run: ( - interaction: AnySelectMenuInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: KittenComponentConfig, ): KittenComponent { - return new KittenComponent( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } + /** + * construct a reusable modal component handler. + */ modal>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler; - run: ( - interaction: ModalSubmitInteraction, - args: InferOptions, - ctx: Ctx, - ) => CommandResultType; - }, + config: KittenComponentConfig, ): KittenComponent { - return new KittenComponent( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } } diff --git a/pkgs/router/src/components.ts b/pkgs/router/src/components.ts index c0e4131..ab6abf8 100644 --- a/pkgs/router/src/components.ts +++ b/pkgs/router/src/components.ts @@ -19,6 +19,22 @@ export type KittenComponentInteraction = | AnySelectMenuInteraction | ModalSubmitInteraction; +export interface KittenComponentConfig< + I extends KittenComponentInteraction, + O extends Record>, + Ctx = any, + Res = unknown, +> { + options?: O; + onError?: ErrorHandler; + run: (interaction: I, args: InferOptions, ctx: Ctx) => Promise | Res; +} + +/** + * handler for routing interactive discord components. + * automatically encodes your typed parameters into the `customId` to persist + * state across interactions. + */ export class KittenComponent< I extends KittenComponentInteraction = KittenComponentInteraction, O extends Record> = Record>, @@ -29,25 +45,23 @@ export class KittenComponent< constructor( public name: string, - public config: { - options?: Record>; - onError?: ErrorHandler; - run: (interaction: I, args: InferOptions, ctx: Ctx) => Promise | Res; - }, + public config: KittenComponentConfig, public middlewares: Middleware[] = [], public errorHandlers: ErrorHandler[] = [], public afterwares: Afterware[] = [], ) {} /** - * @throws {CustomIdTooLong} if the generated custom ID exceeds Discord's 100 character limit. + * safely serializes your component's arguments into a discord-compatible custom ID. + * + * @throws {CustomIdTooLong} if the generated custom ID exceeds discord's 100 character limit. * @returns a custom ID string that encodes the component name and its options' values. */ id( ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] ): string { const keys = Object.keys(this.config.options || {}); - const values = keys.map((key) => encodeURIComponent(String(args?.[key] ?? ""))); + const values = keys.map((key) => encodeURIComponent(String((args as any)?.[key] ?? ""))); const customId = [this.name, ...values].join(":"); if (customId.length > 100) throw new CustomIdTooLong(this.name, customId.length); @@ -55,6 +69,9 @@ export class KittenComponent< return customId; } + /** + * parses the raw custom ID string payload back into typed arguments. + */ parseArgs(customId: string): InferOptions { const [, ...parts] = customId.split(":"); const keys = Object.keys(this.config.options || {}); @@ -75,6 +92,9 @@ export class KittenComponent< return parsed as InferOptions; } + /** + * generate a pre-configured discord.js `ButtonBuilder` instance. + */ button( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -82,6 +102,9 @@ export class KittenComponent< return new ButtonBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `StringSelectMenuBuilder` instance. + */ stringSelectMenu( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -89,6 +112,9 @@ export class KittenComponent< return new StringSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `UserSelectMenuBuilder` instance. + */ userSelectMenu( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -96,6 +122,9 @@ export class KittenComponent< return new UserSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `RoleSelectMenuBuilder` instance. + */ roleSelectMenu( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -103,6 +132,9 @@ export class KittenComponent< return new RoleSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `ChannelSelectMenuBuilder` instance. + */ channelSelectMenu( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -110,6 +142,9 @@ export class KittenComponent< return new ChannelSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `MentionableSelectMenuBuilder` instance. + */ mentionableSelectMenu( this: KittenComponent, ...[args]: {} extends InferOptions ? [args?: InferOptions] : [args: InferOptions] @@ -117,6 +152,9 @@ export class KittenComponent< return new MentionableSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `ModalBuilder` instance. + */ modal( this: KittenComponent, title: string, diff --git a/pkgs/router/src/errors.ts b/pkgs/router/src/errors.ts index 5c580f1..d7dc831 100644 --- a/pkgs/router/src/errors.ts +++ b/pkgs/router/src/errors.ts @@ -1,5 +1,18 @@ import { InteractionReplyOptions } from "discord.js"; +/** + * throw this inside a middleware to intentionally halt execution without triggering + * the error handler propagation chain. + * + * @example + * ```ts + * kitten.builder().use(async (interaction) => { + * if (!isAuthorized) { + * throw new HaltExecution({ content: "unauthorized.", flags: ["Ephemeral"] }); + * } + * }); + * ``` + */ export class HaltExecution extends Error { constructor(public replyPayload?: string | InteractionReplyOptions) { super("execution halted by middleware."); @@ -7,6 +20,16 @@ export class HaltExecution extends Error { } } +/** + * thrown when a generated custom ID exceeds discord's 100 character limit. + * + * @remarks + * this usually happens when you store too much dynamic data in a component's options, + * or if your component name is exceptionally long. + * + * kitten will warn you if you're close to the limit (subscribe to the `warn` event), but + * this error will be thrown if you exceed it. + */ export class CustomIdTooLong extends Error { constructor(componentName: string, length: number) { super( diff --git a/pkgs/router/src/kitten.ts b/pkgs/router/src/kitten.ts index ce9e725..0c2f6a7 100644 --- a/pkgs/router/src/kitten.ts +++ b/pkgs/router/src/kitten.ts @@ -14,19 +14,25 @@ import { type RESTPostAPIApplicationCommandsJSONBody, ApplicationCommandType, PermissionsBitField, - type LocalizationMap, } from "discord.js"; import { HaltExecution } from "./errors"; -import { type Option, type CommandOption, type InferOptions, OPTION_TYPES } from "./options"; -import { KittenComponent, type KittenComponentInteraction } from "./components"; +import { type Option, type CommandOption, OPTION_TYPES } from "./options"; +import { + KittenComponent, + type KittenComponentConfig, + type KittenComponentInteraction, +} from "./components"; import { Command, CommandBuilder, + Subcommand, SubcommandGroupBuilder, ContextMenuCommand, - type IntegrationType, - type InteractionContextType, + type BaseCommandConfig, + type LocalizedDescription, + type ChatCommandConfig, + type ContextMenuConfig, type CommandResultType, type Middleware, type ErrorHandler, @@ -34,16 +40,26 @@ import { } 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>(); @@ -64,115 +80,100 @@ export class Kitten extends EventEmitter { }); } + /** + * 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: { - description: string; - options?: O; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: (interaction: ChatInputCommandInteraction, args: InferOptions) => CommandResultType; - }, + config: ChatCommandConfig, ): Command<{}>; + /** + * define a subcommand group attached directly to the global instance. + */ command( name: string, - config: { - description: string; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - }, + 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) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: (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) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler; - run: (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: { - options?: O; - onError?: ErrorHandler; - run: (interaction: ButtonInteraction, args: InferOptions) => CommandResultType; - }, + config: KittenComponentConfig, ): KittenComponent { return this.builder().button(name, config); } + /** + * define a select menu component attached directly to the global instance. + */ selectMenu>>( name: string, - config: { - options?: O; - onError?: ErrorHandler; - run: (interaction: AnySelectMenuInteraction, args: InferOptions) => CommandResultType; - }, + config: KittenComponentConfig, ): KittenComponent { return this.builder().selectMenu(name, config); } + /** + * define a modal component attached directly to the global instance. + */ modal>>( name: string, - config: { - options?: O; - onError?: ErrorHandler; - run: (interaction: ModalSubmitInteraction, args: InferOptions) => CommandResultType; - }, + 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 @@ -183,19 +184,16 @@ export class Kitten extends EventEmitter { }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) { - if (cmd.type === "user") { - this.userContextMenus.set(cmd.name, cmd); - this.emit("debug", `registered user context menu: "${cmd.name}"`); - } else if (cmd.type === "message") { - this.messageContextMenus.set(cmd.name, cmd); - this.emit("debug", `registered message context menu: "${cmd.name}"`); - } + 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); @@ -221,27 +219,16 @@ export class Kitten extends EventEmitter { private async handleInteraction(interaction: BaseInteraction) { this.emit("debug", `received interaction ID: ${interaction.id}, Type: ${interaction.type}`); - 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); - case interaction.isAnySelectMenu(): - return await this.routeComponent(interaction); - case interaction.isModalSubmit(): - return await this.routeComponent(interaction); - default: - this.emit("debug", `interaction ID: ${interaction.id} could not be classified or handled.`); + 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) { @@ -263,46 +250,20 @@ export class Kitten extends EventEmitter { `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) { - this.emit( - "debug", - `subcommand group "${groupName}" not found under "${interaction.commandName}".`, - ); - return; - } - const subcommand = group.subcommands[subName ?? ""]; - if (!subcommand) { - this.emit( - "debug", - `subcommand "${subName}" not found in group "${groupName}" under "${interaction.commandName}".`, - ); - return; - } - - await this.executeWithMiddlewares( - interaction, - entry.middlewares, - subcommand.config.run, - subcommand.config.options, - undefined, - { - localOnError: subcommand.config.onError, - builderErrorHandlers: entry.errorHandlers, - afterwares: entry.afterwares, - }, - ); + 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) { - const subcommand = entry.subcommands[subName]; - if (!subcommand) { - this.emit( - "debug", - `subcommand "${subName}" not found under "${interaction.commandName}".`, - ); - return; - } + subcommand = entry.subcommands[subName]; + if (!subcommand) return this.emit("debug", `subcommand "${subName}" not found.`); + } + if (subcommand) { await this.executeWithMiddlewares( interaction, entry.middlewares, @@ -438,47 +399,31 @@ export class Kitten extends EventEmitter { let activeError = err; let handled = false; - try { - if (extra?.localOnError) { - this.emit("debug", `executing local error handler for interaction ${interaction.id}`); - await extra.localOnError(activeError, interaction, context); - handled = true; + 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; } - } catch (localErr) { - this.emit("debug", `local error handler threw an error, propagating...`); - activeError = localErr; - } + }; + + if (await handleErr(extra?.localOnError)) handled = true; if (!handled && extra?.builderErrorHandlers) { for (const handler of extra.builderErrorHandlers) { - try { - this.emit("debug", `executing builder error handler for interaction ${interaction.id}`); - await handler(activeError, interaction, context); + if (await handleErr(handler)) { handled = true; break; - } catch (builderErr) { - this.emit( - "debug", - `builder error handler threw an error, propagating to next handler...`, - ); - activeError = builderErr; } } } - if (!handled && this.globalOnError) { - try { - this.emit("debug", `executing global error handler for interaction ${interaction.id}`); - await this.globalOnError(activeError, interaction, context); - handled = true; - } catch (globalErr) { - this.emit( - "debug", - `global error handler threw an error, falling back to default logging.`, - ); - activeError = globalErr; - } - } + if (!handled) handled = await handleErr(this.globalOnError); if (!handled) { const isCommand = "commandName" in interaction; @@ -595,6 +540,7 @@ export class Kitten extends EventEmitter { `routing component for prefix "${prefix}" (customId:${interaction.customId})`, ); const args = component.parseArgs(interaction.customId); + await this.executeWithMiddlewares( interaction, component.middlewares, @@ -618,166 +564,121 @@ export class Kitten extends EventEmitter { 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)) { - 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; - default: - this.emit( - "debug", - `skipping option "${name}" due to unmapped option type: "${opt.type}"`, - ); + 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()) { - if (cmd instanceof SubcommandGroupBuilder) { - const subcommandOptions: APIApplicationCommandOption[] = Object.values(cmd.subcommands).map( - (sub) => ({ - 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), - }), - ); + const payload: any = { + type: ApplicationCommandType.ChatInput, + name: cmd.name, + description: + cmd instanceof SubcommandGroupBuilder ? cmd.description : cmd.config.description, + }; - const groupOptions: APIApplicationCommandOption[] = Object.values(cmd.groups).map( - (group) => ({ + 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) => ({ - 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), - })), - }), - ); - - const payload: RESTPostAPIApplicationCommandsJSONBody = { - type: ApplicationCommandType.ChatInput, - name: cmd.name, - description: cmd.description, - options: [...subcommandOptions, ...groupOptions], - }; - - if (cmd.config?.nameLocalizations) - payload.name_localizations = cmd.config.nameLocalizations; - if (cmd.config?.descriptionLocalizations) - payload.description_localizations = cmd.config.descriptionLocalizations; - if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; - if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; - if (cmd.config?.defaultMemberPermissions !== undefined) { - payload.default_member_permissions = - cmd.config.defaultMemberPermissions === null - ? null - : new PermissionsBitField(cmd.config.defaultMemberPermissions).bitfield.toString(); - } - if (cmd.config?.nsfw !== undefined) payload.nsfw = cmd.config.nsfw; - - payloads.push(payload); + options: Object.values(group.subcommands).map((sub) => this.mapSubcommand(sub)), + })), + ]; + this.applyBaseAppCommandConfig(payload, cmd.config); } else { - const payload: RESTPostAPIApplicationCommandsJSONBody = { - type: ApplicationCommandType.ChatInput, - name: cmd.name, - description: cmd.config.description, - options: this.transformOptionsForDiscord(cmd.config.options), - }; - - if (cmd.config.nameLocalizations) payload.name_localizations = cmd.config.nameLocalizations; - if (cmd.config.descriptionLocalizations) - payload.description_localizations = cmd.config.descriptionLocalizations; - if (cmd.config.integrationTypes) payload.integration_types = cmd.config.integrationTypes; - if (cmd.config.contexts) payload.contexts = cmd.config.contexts; - if (cmd.config.defaultMemberPermissions !== undefined) { - payload.default_member_permissions = - cmd.config.defaultMemberPermissions === null - ? null - : new PermissionsBitField(cmd.config.defaultMemberPermissions).bitfield.toString(); - } - if (cmd.config.nsfw !== undefined) payload.nsfw = cmd.config.nsfw; - - payloads.push(payload); + payload.options = this.transformOptionsForDiscord(cmd.config.options); + this.applyBaseAppCommandConfig(payload, cmd.config); } + + payloads.push(payload); } // context menus for (const cmd of this.userContextMenus.values()) { - const payload: RESTPostAPIApplicationCommandsJSONBody = { - type: ApplicationCommandType.User, - name: cmd.name, - }; - - if (cmd.config?.nameLocalizations) payload.name_localizations = cmd.config.nameLocalizations; - if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; - if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; - if (cmd.config?.defaultMemberPermissions !== undefined) { - payload.default_member_permissions = - cmd.config.defaultMemberPermissions === null - ? null - : new PermissionsBitField(cmd.config.defaultMemberPermissions).bitfield.toString(); - } - if (cmd.config?.nsfw !== undefined) payload.nsfw = cmd.config.nsfw; - - payloads.push(payload); + payloads.push( + this.applyBaseAppCommandConfig( + { + type: ApplicationCommandType.User, + name: cmd.name, + }, + cmd.config, + ), + ); } for (const cmd of this.messageContextMenus.values()) { - const payload: RESTPostAPIApplicationCommandsJSONBody = { - type: ApplicationCommandType.Message, - name: cmd.name, - }; - - if (cmd.config?.nameLocalizations) payload.name_localizations = cmd.config.nameLocalizations; - if (cmd.config?.integrationTypes) payload.integration_types = cmd.config.integrationTypes; - if (cmd.config?.contexts) payload.contexts = cmd.config.contexts; - if (cmd.config?.defaultMemberPermissions !== undefined) { - payload.default_member_permissions = - cmd.config.defaultMemberPermissions === null - ? null - : new PermissionsBitField(cmd.config.defaultMemberPermissions).bitfield.toString(); - } - if (cmd.config?.nsfw !== undefined) payload.nsfw = cmd.config.nsfw; - - payloads.push(payload); + payloads.push( + this.applyBaseAppCommandConfig( + { + type: ApplicationCommandType.Message, + name: cmd.name, + }, + cmd.config, + ), + ); } if (!this.client.application) { @@ -813,9 +714,7 @@ export class Kitten extends EventEmitter { required: opt.required, }; - if (opt.autocomplete) { - payload.autocomplete = true; - } + if (opt.autocomplete) payload.autocomplete = true; if (opt.choices) { payload.choices = opt.choices.map((c) => ({ @@ -825,13 +724,9 @@ export class Kitten extends EventEmitter { })); } - if (opt.nameLocalizations) { - payload.name_localizations = opt.nameLocalizations; - } - - if (opt.descriptionLocalizations) { + if (opt.nameLocalizations) payload.name_localizations = opt.nameLocalizations; + if (opt.descriptionLocalizations) payload.description_localizations = opt.descriptionLocalizations; - } return payload as APIApplicationCommandBasicOption; }); diff --git a/pkgs/router/src/options.ts b/pkgs/router/src/options.ts index c491833..44ab762 100644 --- a/pkgs/router/src/options.ts +++ b/pkgs/router/src/options.ts @@ -21,7 +21,7 @@ export const OPTION_TYPES = { number: 10, attachment: 11, } as const; -type OptionType = keyof typeof OPTION_TYPES; +export type OptionType = keyof typeof OPTION_TYPES; export interface OptionChoice { name: string; @@ -29,17 +29,18 @@ export interface OptionChoice { value: T; } -export interface Option { - type: OptionType; - description?: string; - required: Req; - autocomplete?: ( - interaction: AutocompleteInteraction, - value: T, - ) => Promise | ApplicationCommandOptionChoiceData[]; +export interface OptionConfig { + required?: boolean; + autocomplete?: AutocompleteFn; choices?: OptionChoice[]; nameLocalizations?: LocalizationMap; descriptionLocalizations?: LocalizationMap; +} + +export interface Option extends OptionConfig { + type: OptionType; + description?: string; + required: Req; __type?: T; } @@ -47,6 +48,11 @@ export interface CommandOption extends Option { description: string; } +/** + * recursively infers strict typescript types from your option schema. + * options marked as `required: true` will be inferred as `T`, while optional + * parameters will be inferred as `T | undefined`. + */ export type InferOptions = { [K in keyof O]: O[K] extends Option ? Required extends true @@ -55,94 +61,42 @@ export type InferOptions = { : never; }; +/** + * a helper function to provide dynamic autocomplete choices to the user. + * kitten handles routing the autocomplete interaction and running this helper automatically. + */ export type AutocompleteFn = ( interaction: AutocompleteInteraction, value: T, ) => Promise | ApplicationCommandOptionChoiceData[]; export interface OptionBuilder { - ( - description: string, - config: { - required: true; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption; - ( - description: string, - config?: { - required?: false; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption; - (config: { - required: true; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }): Option; - (config?: { - required?: false; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }): Option; + (description: string, config: OptionConfig & { required: true }): CommandOption; + (description: string, config?: OptionConfig & { required?: false }): CommandOption; + (config: OptionConfig & { required: true }): Option; + (config?: OptionConfig & { required?: false }): Option; - required( - description: string, - config?: { - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption; + required(description: string, config?: Omit, "required">): CommandOption; optional( description: string, - config?: { - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, + config?: Omit, "required">, ): CommandOption; } const createOption = (type: OptionType): OptionBuilder => { - const fn = ( - descriptionOrConfig?: - | string - | { - required?: boolean; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - config?: { - required?: boolean; - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, + const build = ( + descOrConfig?: string | OptionConfig, + configObj?: OptionConfig, + overrideReq?: boolean, ): any => { - const hasDescription = typeof descriptionOrConfig === "string"; - const description = hasDescription ? descriptionOrConfig : undefined; - const actualConfig = hasDescription ? config : descriptionOrConfig; + const hasDesc = typeof descOrConfig === "string"; + const description = hasDesc ? descOrConfig : undefined; + const actualConfig = hasDesc ? configObj : (descOrConfig as OptionConfig); return { type, description, - required: actualConfig?.required ?? false, + required: overrideReq ?? actualConfig?.required ?? false, autocomplete: actualConfig?.autocomplete, choices: actualConfig?.choices, nameLocalizations: actualConfig?.nameLocalizations, @@ -150,45 +104,25 @@ const createOption = (type: OptionType): OptionBuilder => { }; }; - fn.required = ( - description: string, - config?: { - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ) => { - return fn(description, { - required: true, - autocomplete: config?.autocomplete, - choices: config?.choices, - nameLocalizations: config?.nameLocalizations, - descriptionLocalizations: config?.descriptionLocalizations, - }); - }; - - fn.optional = ( - description: string, - config?: { - autocomplete?: AutocompleteFn; - choices?: OptionChoice[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ) => { - return fn(description, { - required: false, - autocomplete: config?.autocomplete, - choices: config?.choices, - nameLocalizations: config?.nameLocalizations, - descriptionLocalizations: config?.descriptionLocalizations, - }); - }; + const fn: any = (doc?: any, c?: any) => build(doc, c); + fn.required = (d: string, c?: any) => build(d, c, true); + fn.optional = (d: string, c?: any) => build(d, c, false); return fn as OptionBuilder; }; +/** + * definitions for command and component options. these generate type-safe parameters + * directly inside of your interaction's `run` callback. + * + * @example + * ```ts + * options: { + * user: option.user.required("the user to get information about"), + * ephemeral: option.boolean.optional("whether the response should be ephemeral"), + * } + * ``` + */ export const option = { string: createOption("string"), integer: createOption("integer"),