all repos — kitten @ 6742b6ccdb8cfbedfbee82d9baa7c6da27190208

router: much refactor & dry-ing
vi did:web:vt3e.cat
Thu, 09 Jul 2026 03:59:41 +0100
commit

6742b6ccdb8cfbedfbee82d9baa7c6da27190208

parent

eeea7493aaf03e48a3673dd02e78c109ef7be762

A 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.
M pkgs/router/package.jsonpkgs/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",
M pkgs/router/src/commands.tspkgs/router/src/commands.ts

@@ -9,7 +9,11 @@ ModalSubmitInteraction,

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 INTEGRATION_TYPES];

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<I extends BaseInteraction = BaseInteraction, InCtx = any, OutCtx = any> = ( interaction: I, ctx: InCtx, ) => Promise<OutCtx | void> | 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<I extends BaseInteraction = BaseInteraction, Ctx = any> = ( err: unknown, interaction: I, ctx: Ctx, ) => Promise<unknown> | 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<I extends BaseInteraction = BaseInteraction, Ctx = any, Res = unknown> = ( interaction: I, ctx: Ctx,

@@ -55,17 +76,46 @@ error: unknown | null,

result: Res | undefined, ) => Promise<void> | void; +export interface BaseCommandConfig<I extends BaseInteraction = BaseInteraction, Ctx = any> { + integrationTypes?: IntegrationType[]; + contexts?: InteractionContextType[]; + nameLocalizations?: LocalizationMap; + defaultMemberPermissions?: string | number | bigint | any; + nsfw?: boolean; + onError?: ErrorHandler<I, Ctx>; +} + +export interface LocalizedDescription { + description: string; + descriptionLocalizations?: LocalizationMap; +} + +export interface ChatCommandConfig< + O extends Record<string, CommandOption<any, any>>, + Ctx = any, + Res = unknown, +> + extends BaseCommandConfig<ChatInputCommandInteraction, Ctx>, LocalizedDescription { + options?: O; + run: ( + interaction: ChatInputCommandInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => CommandResultType<Res>; +} + +export interface ContextMenuConfig< + I extends BaseInteraction, + Ctx = any, + Res = unknown, +> extends BaseCommandConfig<I, Ctx> { + run: (interaction: I, ctx: Ctx) => CommandResultType<Res>; +} + export class Subcommand { constructor( public name: string, - public config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: Record<string, CommandOption<any, any>>; - onError?: ErrorHandler<ChatInputCommandInteraction, any>; - run: Function; - }, + public config: ChatCommandConfig<any, any, any>, ) {} }

@@ -81,24 +131,12 @@ descriptionLocalizations?: LocalizationMap;

}, ) {} + /** + * append a subcommand to this specific group. + */ subcommand<O extends Record<string, CommandOption<any, any>>, R extends Res>( name: string, - config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: O; - onError?: ( - err: unknown, - interaction: ChatInputCommandInteraction, - ctx: Ctx, - ) => Promise<unknown> | unknown; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: ChatCommandConfig<O, Ctx, R>, ): this { this.subcommands[name] = new Subcommand(name, config); return this;

@@ -115,46 +153,26 @@ public description: string,

public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], public afterwares: Afterware<any, any, any>[] = [], - public config?: { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - }, + public config?: BaseCommandConfig<ChatInputCommandInteraction, Ctx> & LocalizedDescription, ) {} + /** + * chain a loose subcommand onto this builder. + */ subcommand<O extends Record<string, CommandOption<any, any>>, R extends Res>( name: string, - config: { - description: string; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - options?: O; - onError?: ( - err: unknown, - interaction: ChatInputCommandInteraction, - ctx: Ctx, - ) => Promise<unknown> | unknown; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: ChatCommandConfig<O, Ctx, R>, ): 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<Ctx, Res>) => void, ): this { const group = new SubcommandGroup<Ctx, Res>(name, config.description, config);

@@ -167,22 +185,7 @@

export class Command<Ctx = any, Res = unknown> { constructor( public name: string, - public config: { - description: string; - options?: Record<string, CommandOption<any, any>>; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler<ChatInputCommandInteraction, Ctx>; - run: ( - interaction: ChatInputCommandInteraction, - args: any, - ctx: Ctx, - ) => CommandResultType<Res>; - }, + public config: ChatCommandConfig<any, Ctx, Res>, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], public afterwares: Afterware<any, any, any>[] = [],

@@ -201,17 +204,14 @@ public run: (interaction: I, ctx: Ctx) => CommandResultType<Res>,

public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], public afterwares: Afterware<any, any, any>[] = [], - public config?: { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler<I, Ctx>; - }, + public config?: BaseCommandConfig<I, Ctx>, ) {} } +/** + * a chainable builder for creating commands, context menus, and components with shared + * context, middleware, error handlers, and afterwares. + */ export class CommandBuilder<Ctx = {}, Res = unknown> { constructor( private middlewares: Middleware<any, any, any>[] = [],

@@ -219,6 +219,10 @@ private errorHandlers: ErrorHandler<any, any>[] = [],

private afterwares: Afterware<any, any, Res>[] = [], ) {} + /** + * attach a middleware to this builder. any context returned will be merged and + * strictly typed for all subsequent middlewares and handlers. + */ use<NewCtx extends Record<string, any>>( mw: (interaction: BaseInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, ): CommandBuilder<Ctx & NewCtx, Res> {

@@ -229,6 +233,10 @@ this.afterwares,

); } + /** + * 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<BaseInteraction, Ctx>): CommandBuilder<Ctx, Res> { return new CommandBuilder<Ctx, Res>( this.middlewares,

@@ -237,6 +245,17 @@ this.afterwares,

); } + /** + * attach an afterware to this builder. these strictly enforce the return type of your commands + * to prevent type safety degradation. + * + * @example + * ```ts + * .after<MyResult>(async (interaction, ctx, err, result) => { + * // result is strictly typed as: MyResult | undefined + * }) + * ``` + */ after<NewRes>(handler: Afterware<BaseInteraction, Ctx, NewRes>): CommandBuilder<Ctx, NewRes> { return new CommandBuilder<Ctx, NewRes>(this.middlewares, this.errorHandlers, [ ...this.afterwares,

@@ -244,37 +263,20 @@ handler as any,

]); } + /** + * construct a top-level chat input command. + */ command<O extends Record<string, CommandOption<any, any>>, 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<ChatInputCommandInteraction, Ctx>; - run: ( - interaction: ChatInputCommandInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: ChatCommandConfig<O, Ctx, R>, ): Command<Ctx, R>; + /** + * 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<ChatInputCommandInteraction, Ctx> & LocalizedDescription, ): SubcommandGroupBuilder<Ctx, Res>; command(name: string, config: any): any {

@@ -293,166 +295,91 @@ config.description,

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<R extends Res>( - name: string, - runOrConfig: - | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler<UserContextMenuCommandInteraction, Ctx>; - run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>; - }, - ): ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R> { - if (typeof runOrConfig === "function") { - return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R>( - name, - "user", - runOrConfig, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); - } - - return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R>( + private createContextMenu< + I extends UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction, + R extends Res, + >(name: string, type: "user" | "message", runOrConfig: any) { + const isFn = typeof runOrConfig === "function"; + return new ContextMenuCommand<Ctx, I, R>( 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<R extends Res>( + name: string, + runOrConfig: + | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>) + | ContextMenuConfig<UserContextMenuCommandInteraction, Ctx, R>, + ): ContextMenuCommand<Ctx, UserContextMenuCommandInteraction, R> { + return this.createContextMenu(name, "user", runOrConfig); + } + + /** + * construct a message context menu command. + */ messageContextMenu<R extends Res>( name: string, runOrConfig: | ((interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType<R>) - | { - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler<MessageContextMenuCommandInteraction, Ctx>; - run: ( - interaction: MessageContextMenuCommandInteraction, - ctx: Ctx, - ) => CommandResultType<R>; - }, + | ContextMenuConfig<MessageContextMenuCommandInteraction, Ctx, R>, ): ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R> { - if (typeof runOrConfig === "function") { - return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R>( - name, - "message", - runOrConfig, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); - } + return this.createContextMenu(name, "message", runOrConfig); + } - return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction, R>( + private createComponent< + I extends KittenComponentInteraction, + O extends Record<string, Option<any, any>>, + R extends Res, + >(name: string, config: any): KittenComponent<I, O, Ctx, R> { + return new KittenComponent<I, O, Ctx, R>( 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<O extends Record<string, Option<any, any>>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler<ButtonInteraction, Ctx>; - run: ( - interaction: ButtonInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: KittenComponentConfig<ButtonInteraction, O, Ctx, R>, ): KittenComponent<ButtonInteraction, O, Ctx, R> { - return new KittenComponent<ButtonInteraction, O, Ctx, R>( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } + /** + * construct a reusable select menu component handler. + */ selectMenu<O extends Record<string, Option<any, any>>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler<AnySelectMenuInteraction, Ctx>; - run: ( - interaction: AnySelectMenuInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: KittenComponentConfig<AnySelectMenuInteraction, O, Ctx, R>, ): KittenComponent<AnySelectMenuInteraction, O, Ctx, R> { - return new KittenComponent<AnySelectMenuInteraction, O, Ctx, R>( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } + /** + * construct a reusable modal component handler. + */ modal<O extends Record<string, Option<any, any>>, R extends Res>( name: string, - config: { - options?: O; - onError?: ErrorHandler<ModalSubmitInteraction, Ctx>; - run: ( - interaction: ModalSubmitInteraction, - args: InferOptions<O>, - ctx: Ctx, - ) => CommandResultType<R>; - }, + config: KittenComponentConfig<ModalSubmitInteraction, O, Ctx, R>, ): KittenComponent<ModalSubmitInteraction, O, Ctx, R> { - return new KittenComponent<ModalSubmitInteraction, O, Ctx, R>( - name, - config, - this.middlewares, - this.errorHandlers, - this.afterwares, - ); + return this.createComponent(name, config); } }
M pkgs/router/src/components.tspkgs/router/src/components.ts

@@ -19,6 +19,22 @@ | ButtonInteraction

| AnySelectMenuInteraction | ModalSubmitInteraction; +export interface KittenComponentConfig< + I extends KittenComponentInteraction, + O extends Record<string, Option<any, any>>, + Ctx = any, + Res = unknown, +> { + options?: O; + onError?: ErrorHandler<I, Ctx>; + run: (interaction: I, args: InferOptions<O>, ctx: Ctx) => Promise<Res> | 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<string, Option<any, any>> = Record<string, Option<any, any>>,

@@ -29,25 +45,23 @@ type = "component" as const;

constructor( public name: string, - public config: { - options?: Record<string, Option<any, any>>; - onError?: ErrorHandler<I, Ctx>; - run: (interaction: I, args: InferOptions<O>, ctx: Ctx) => Promise<Res> | Res; - }, + public config: KittenComponentConfig<I, O, Ctx, Res>, public middlewares: Middleware<any, any, any>[] = [], public errorHandlers: ErrorHandler<any, any>[] = [], public afterwares: Afterware<any, any, any>[] = [], ) {} /** - * @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<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>] ): 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 @@

return customId; } + /** + * parses the raw custom ID string payload back into typed arguments. + */ parseArgs(customId: string): InferOptions<O> { const [, ...parts] = customId.split(":"); const keys = Object.keys(this.config.options || {});

@@ -75,6 +92,9 @@

return parsed as InferOptions<O>; } + /** + * generate a pre-configured discord.js `ButtonBuilder` instance. + */ button( this: KittenComponent<ButtonInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -82,6 +102,9 @@ ): ButtonBuilder {

return new ButtonBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `StringSelectMenuBuilder` instance. + */ stringSelectMenu( this: KittenComponent<AnySelectMenuInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -89,6 +112,9 @@ ): StringSelectMenuBuilder {

return new StringSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `UserSelectMenuBuilder` instance. + */ userSelectMenu( this: KittenComponent<AnySelectMenuInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -96,6 +122,9 @@ ): UserSelectMenuBuilder {

return new UserSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `RoleSelectMenuBuilder` instance. + */ roleSelectMenu( this: KittenComponent<AnySelectMenuInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -103,6 +132,9 @@ ): RoleSelectMenuBuilder {

return new RoleSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `ChannelSelectMenuBuilder` instance. + */ channelSelectMenu( this: KittenComponent<AnySelectMenuInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -110,6 +142,9 @@ ): ChannelSelectMenuBuilder {

return new ChannelSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `MentionableSelectMenuBuilder` instance. + */ mentionableSelectMenu( this: KittenComponent<AnySelectMenuInteraction, O, Ctx, Res>, ...[args]: {} extends InferOptions<O> ? [args?: InferOptions<O>] : [args: InferOptions<O>]

@@ -117,6 +152,9 @@ ): MentionableSelectMenuBuilder {

return new MentionableSelectMenuBuilder().setCustomId(this.id(args as any)); } + /** + * generate a pre-configured discord.js `ModalBuilder` instance. + */ modal( this: KittenComponent<ModalSubmitInteraction, O, Ctx, Res>, title: string,
M pkgs/router/src/errors.tspkgs/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 @@ this.name = "HaltExecution";

} } +/** + * 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(
M pkgs/router/src/kitten.tspkgs/router/src/kitten.ts

@@ -14,19 +14,25 @@ BaseInteraction,

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 @@ type Afterware,

} from "./commands"; export interface KittenOptions { + /** a global fallback error handler. */ onError?: ErrorHandler<BaseInteraction, any>; } export interface KittenEvents { + /** diagnostics and routing traces. */ debug: [message: string, data?: Record<string, any>]; + /** command registration and API sync notices. */ info: [message: string, data?: Record<string, any>]; + /** non-fatal runtime warning telemetry. */ warn: [message: string, data?: Record<string, any>]; + /** fallback system telemetry for unhandled execution failures. */ error: [message: string, data?: Record<string, any>]; } +/** + * the main entry point for routing your bot. + * + * @see {@link KittenEvents} for telemetry events you can subscribe to. + */ export class Kitten extends EventEmitter<KittenEvents> { private commands = new Map<string, Command<any, any> | SubcommandGroupBuilder<any, any>>(); private userContextMenus = new Map<string, ContextMenuCommand<any, any, any>>();

@@ -64,115 +80,100 @@ 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<BaseInteraction, any>) { this.globalOnError = handler; return this; } + /** + * define a chat input command attached directly to the global instance. + */ command<O extends Record<string, CommandOption<any, any>>>( name: string, - config: { - description: string; - options?: O; - integrationTypes?: IntegrationType[]; - contexts?: InteractionContextType[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - defaultMemberPermissions?: string | number | bigint | any; - nsfw?: boolean; - onError?: ErrorHandler<ChatInputCommandInteraction, {}>; - run: (interaction: ChatInputCommandInteraction, args: InferOptions<O>) => CommandResultType; - }, + config: ChatCommandConfig<O, {}, unknown>, ): 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<ChatInputCommandInteraction, {}> & 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<UserContextMenuCommandInteraction, {}>; - run: (interaction: UserContextMenuCommandInteraction) => CommandResultType; - }, + | ContextMenuConfig<UserContextMenuCommandInteraction, {}, unknown>, ): 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<MessageContextMenuCommandInteraction, {}>; - run: (interaction: MessageContextMenuCommandInteraction) => CommandResultType; - }, + | ContextMenuConfig<MessageContextMenuCommandInteraction, {}, unknown>, ): ContextMenuCommand<{}, MessageContextMenuCommandInteraction> { return this.builder().messageContextMenu(name, runOrConfig as any); } + /** + * define a button component attached directly to the global instance. + */ button<O extends Record<string, Option<any, any>>>( name: string, - config: { - options?: O; - onError?: ErrorHandler<ButtonInteraction, {}>; - run: (interaction: ButtonInteraction, args: InferOptions<O>) => CommandResultType; - }, + config: KittenComponentConfig<ButtonInteraction, O, {}, unknown>, ): KittenComponent<ButtonInteraction, O, {}> { return this.builder().button(name, config); } + /** + * define a select menu component attached directly to the global instance. + */ selectMenu<O extends Record<string, Option<any, any>>>( name: string, - config: { - options?: O; - onError?: ErrorHandler<AnySelectMenuInteraction, {}>; - run: (interaction: AnySelectMenuInteraction, args: InferOptions<O>) => CommandResultType; - }, + config: KittenComponentConfig<AnySelectMenuInteraction, O, {}, unknown>, ): KittenComponent<AnySelectMenuInteraction, O, {}> { return this.builder().selectMenu(name, config); } + /** + * define a modal component attached directly to the global instance. + */ modal<O extends Record<string, Option<any, any>>>( name: string, - config: { - options?: O; - onError?: ErrorHandler<ModalSubmitInteraction, {}>; - run: (interaction: ModalSubmitInteraction, args: InferOptions<O>) => CommandResultType; - }, + config: KittenComponentConfig<ModalSubmitInteraction, O, {}, unknown>, ): KittenComponent<ModalSubmitInteraction, O, {}> { 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<any, any>

@@ -183,19 +184,16 @@ components?: KittenComponent<any, any, any, any>[];

}) { 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 @@

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"); + 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); + } - // 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.`); - } + this.emit("debug", `interaction ID: ${interaction.id} could not be classified or handled.`); } private async routeCommand(interaction: ChatInputCommandInteraction) {

@@ -263,46 +250,20 @@ "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) { - 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 @@

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<any, any>) => { + 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 @@ "debug",

`routing component for prefix "${prefix}" (customId:${interaction.customId})`, ); const args = component.parseArgs(interaction.customId); + await this.executeWithMiddlewares( interaction, component.middlewares,

@@ -618,166 +564,121 @@ const parsed: Record<string, any> = {};

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; + payload.options = this.transformOptionsForDiscord(cmd.config.options); + this.applyBaseAppCommandConfig(payload, cmd.config); + } - payloads.push(payload); - } + 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 @@ type: OPTION_TYPES[opt.type],

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 @@ value: c.value,

})); } - 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; });
M pkgs/router/src/options.tspkgs/router/src/options.ts

@@ -21,7 +21,7 @@ mentionable: 9,

number: 10, attachment: 11, } as const; -type OptionType = keyof typeof OPTION_TYPES; +export type OptionType = keyof typeof OPTION_TYPES; export interface OptionChoice<T> { name: string;

@@ -29,17 +29,18 @@ nameLocalizations?: LocalizationMap;

value: T; } -export interface Option<T, Req extends boolean> { +export interface OptionConfig<T> { + required?: boolean; + autocomplete?: AutocompleteFn<T>; + choices?: OptionChoice<T>[]; + nameLocalizations?: LocalizationMap; + descriptionLocalizations?: LocalizationMap; +} + +export interface Option<T, Req extends boolean> extends OptionConfig<T> { type: OptionType; description?: string; required: Req; - autocomplete?: ( - interaction: AutocompleteInteraction, - value: T, - ) => Promise<ApplicationCommandOptionChoiceData[]> | ApplicationCommandOptionChoiceData[]; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; __type?: T; }

@@ -47,6 +48,11 @@ export interface CommandOption<T, Req extends boolean> extends Option<T, Req> {

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<O> = { [K in keyof O]: O[K] extends Option<infer Type, infer Required> ? Required extends true

@@ -55,94 +61,42 @@ : Type | undefined

: 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<T> = ( interaction: AutocompleteInteraction, value: T, ) => Promise<ApplicationCommandOptionChoiceData[]> | ApplicationCommandOptionChoiceData[]; export interface OptionBuilder<T> { - ( - description: string, - config: { - required: true; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption<T, true>; - ( - description: string, - config?: { - required?: false; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption<T, false>; - (config: { - required: true; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }): Option<T, true>; - (config?: { - required?: false; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }): Option<T, false>; + (description: string, config: OptionConfig<T> & { required: true }): CommandOption<T, true>; + (description: string, config?: OptionConfig<T> & { required?: false }): CommandOption<T, false>; + (config: OptionConfig<T> & { required: true }): Option<T, true>; + (config?: OptionConfig<T> & { required?: false }): Option<T, false>; - required( - description: string, - config?: { - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - ): CommandOption<T, true>; + required(description: string, config?: Omit<OptionConfig<T>, "required">): CommandOption<T, true>; optional( description: string, - config?: { - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, + config?: Omit<OptionConfig<T>, "required">, ): CommandOption<T, false>; } const createOption = <T>(type: OptionType): OptionBuilder<T> => { - const fn = ( - descriptionOrConfig?: - | string - | { - required?: boolean; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, - config?: { - required?: boolean; - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - nameLocalizations?: LocalizationMap; - descriptionLocalizations?: LocalizationMap; - }, + const build = ( + descOrConfig?: string | OptionConfig<T>, + configObj?: OptionConfig<T>, + 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<T>); 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 @@ descriptionLocalizations: actualConfig?.descriptionLocalizations,

}; }; - fn.required = ( - description: string, - config?: { - autocomplete?: AutocompleteFn<T>; - choices?: OptionChoice<T>[]; - 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<T>; - choices?: OptionChoice<T>[]; - 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<T>; }; +/** + * 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>("string"), integer: createOption<number>("integer"),