diff --git a/bun.lock b/bun.lock index 783b261..bd67fce 100644 --- a/bun.lock +++ b/bun.lock @@ -28,8 +28,12 @@ }, "pkgs/gateway": { "name": "@purrkit/gateway", + "version": "1.0.0", + "dependencies": { + "@purrkit/types": "workspace:*", + }, "devDependencies": { - "@types/bun": "latest", + "@types/node": "latest", }, "peerDependencies": { "typescript": "^5", @@ -50,7 +54,7 @@ }, "pkgs/rest": { "name": "@purrkit/rest", - "version": "1.1.1", + "version": "1.1.2", "dependencies": { "@purrkit/types": "workspace:*", }, @@ -63,7 +67,7 @@ }, "pkgs/router": { "name": "@purrkit/router", - "version": "1.0.3", + "version": "1.4.0", "devDependencies": { "@types/bun": "latest", }, diff --git a/pkgs/router/GET-STARTED.md b/pkgs/router/GET-STARTED.md index 456c0dd..cbadf43 100644 --- a/pkgs/router/GET-STARTED.md +++ b/pkgs/router/GET-STARTED.md @@ -1,3 +1,5 @@ +# Getting Started with Kitten + hi! welcome to kitten, this guide will help you get started. ## table of contents @@ -5,9 +7,11 @@ hi! welcome to kitten, this guide will help you get started. - [bootstrapping](#bootstrapping) - [defining your first command](#defining-your-first-command) - [adding subcommands](#adding-subcommands) +- [context menus](#context-menus) - [interactive components](#interactive-components) - [adding autocomplete](#adding-autocomplete) - [using middleware](#using-middleware) +- [handling errors](#handling-errors) - [registering our commands and components](#registering-our-commands-and-components) ## bootstrapping @@ -89,6 +93,27 @@ configCommand.subcommand("set", { }); ``` +## context menus + +kitten supports both User and Message context menu commands with strict type +inference for your targets. + +```ts +const reportUser = kitten.userContextMenu("Report User", { + async run(interaction, ctx) { + const targetUser = interaction.targetUser; // typed as User + await interaction.reply({ content: `reporting ${targetUser.username}...` }); + }, +}); + +const bookmarkMessage = kitten.messageContextMenu("bookmark message", { + async run(interaction, ctx) { + const targetMessage = interaction.targetMessage; // typed as Message + await interaction.reply({ content: `bookmarked: "${targetMessage.content}"` }); + }, +}); +``` + ## interactive components kitten simplifies routing and parsing custom IDs for buttons, select menus, and @@ -212,6 +237,85 @@ const banCommand = authorised.command("ban", { }); ``` +## handling errors + +kitten implements a multi-level, context-aware error propagation pipeline. if an +error is thrown in middleware or command, your accumulated context up to that point +is preserved and supplied to the handler. + +kitten searches for handlers up the chain: + +1. local error handlers defined on the command/component configuration. +2. builder-level error handlers configured via `.onError()`. +3. global error handler configured on your `Kitten` instance. + +if a handler resolves without throwing, propagation stops. if it throws, that +new error is propagated up to the next tier. + +if kitten experiences an uncaught error, it will log it to the console and +reply with a generic error message to the user. + +### global error handler + +note that context here is just typed as `any` since it's not known what the +context will be at this point. though, we could possibly make use of type +narrowing here in future. + +```ts +const kitten = new Kitten(client, { + onError(err, interaction, ctx) { + console.error("uncaught global exception:", err); + }, +}); + +kitten.onError((err, interaction, ctx) => { + console.error("uncaught global exception:", err); +}); +``` + +### builder-level error handler + +```ts +const accountGroup = kitten + .builder() + .use(async (interaction) => { + const account = await whatever(); + return { account }; + }) + .onError(async (err, interaction, ctx) => { + // ctx.account is accessible if the error occurred after resolved. + console.error(`failure on account: ${ctx.account?.id}`, err); + if (interaction.isRepliable()) { + await interaction.reply({ + content: "command could not be processed.", + flags: ["Ephemeral"], + }); + } + }); +``` + +### local error handler + +```ts +accountGroup.command("account", { + description: "manage your account", + options: { + username: option.string({ + description: "your username", + required: true, + }), + }, + onError(err, interaction, ctx) { + // handles only failures specific to this command. + console.warn(`failed to execute command for ${ctx.account?.id}`, err); + }, + async run(interaction, { amount }, ctx) { + await whatever2(); + await interaction.reply("Invoice paid successfully!"); + }, +}); +``` + ## registering our commands and components and we're almost there! the final step is to register our commands and @@ -222,8 +326,8 @@ you should register all of your commands and components before calling ```ts kitten.register({ - commands: [whoisCommand, configCommand, banCommand], - buttons: [closeTicketButton], + commands: [whoisCommand, configCommand, banCommand, reportUser, bookmarkMessage], + components: [closeTicketButton], }); ``` diff --git a/pkgs/router/changelogs/1.4.0.md b/pkgs/router/changelogs/1.4.0.md new file mode 100644 index 0000000..046efaf --- /dev/null +++ b/pkgs/router/changelogs/1.4.0.md @@ -0,0 +1,22 @@ +# [1.4.0] 2026-07-07 + +in this release i added hirearchical unhandled exception handling, exceptions +can now be caught, handled, or rethrown across three separate scopes: + +- local level (commands, subcommands, context menus, and components), +- builder level (shared across all commands and components produced by a + specific builder), and +- global level (on your main Kitten instance). + +you can now attach `.onError` handlers at any of these levels. if a handler +resolves without throwing, propagation stops. if it throws, the error is bubbled +up to the next tier. + +these exception handlers are also context-aware! if an error is thrown midway +through middleware execution, the handler will receive whatever context was +successfully accumulated up to that point. + +you can find more information under the "handling errors" section of the +[Getting Started] guide + +[Getting Started]: ../GET-STARTED.md diff --git a/pkgs/router/package.json b/pkgs/router/package.json index a169812..5889a01 100644 --- a/pkgs/router/package.json +++ b/pkgs/router/package.json @@ -1,6 +1,6 @@ { "name": "@purrkit/router", - "version": "1.3.0", + "version": "1.4.0", "license": "EUPL-1.2", "author": { "name": "apr", diff --git a/pkgs/router/src/commands.ts b/pkgs/router/src/commands.ts index 910d335..fa2691e 100644 --- a/pkgs/router/src/commands.ts +++ b/pkgs/router/src/commands.ts @@ -36,12 +36,24 @@ export type IntegrationType = (typeof INTEGRATION_TYPES)[keyof typeof INTEGRATIO export type InteractionContextType = (typeof INTERACTION_CONTEXTS)[keyof typeof INTERACTION_CONTEXTS]; +export type Middleware = ( + interaction: I, + ctx: InCtx, +) => Promise | OutCtx | void; + +export type ErrorHandler = ( + err: unknown, + interaction: I, + ctx: Ctx, +) => Promise | unknown; + export class Subcommand { constructor( public name: string, public config: { description: string; options?: Record>; + onError?: ErrorHandler; run: Function; }, ) {} @@ -60,6 +72,11 @@ export class SubcommandGroup { config: { description: string; options?: O; + onError?: ( + err: unknown, + interaction: ChatInputCommandInteraction, + ctx: Ctx, + ) => Promise | unknown; run: ( interaction: ChatInputCommandInteraction, args: InferOptions, @@ -79,7 +96,8 @@ export class SubcommandGroupBuilder { constructor( public name: string, public description: string, - public middlewares: Function[] = [], + public middlewares: Middleware[] = [], + public errorHandlers: ErrorHandler[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; @@ -91,6 +109,11 @@ export class SubcommandGroupBuilder { config: { description: string; options?: O; + onError?: ( + err: unknown, + interaction: ChatInputCommandInteraction, + ctx: Ctx, + ) => Promise | unknown; run: ( interaction: ChatInputCommandInteraction, args: InferOptions, @@ -114,7 +137,7 @@ export class SubcommandGroupBuilder { } } -export class Command { +export class Command { constructor( public name: string, public config: { @@ -122,32 +145,46 @@ export class Command { options?: Record>; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: Function; + onError?: ErrorHandler; + run: (interaction: ChatInputCommandInteraction, args: any, ctx: Ctx) => CommandResultType; }, - public middlewares: Function[] = [], + public middlewares: Middleware[] = [], + public errorHandlers: ErrorHandler[] = [], ) {} } -export class ContextMenuCommand { +export class ContextMenuCommand< + Ctx = any, + I extends UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction = any, +> { constructor( public name: string, public type: "user" | "message", - public run: Function, - public middlewares: Function[] = [], + public run: (interaction: I, ctx: Ctx) => Promise | unknown, + public middlewares: Middleware[] = [], + public errorHandlers: ErrorHandler[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler; }, ) {} } export class CommandBuilder { - constructor(private middlewares: Function[] = []) {} + constructor( + private middlewares: Middleware[] = [], + private errorHandlers: ErrorHandler[] = [], + ) {} use>( mw: (interaction: BaseInteraction, ctx: Ctx) => Promise | NewCtx | void, ): CommandBuilder { - return new CommandBuilder([...this.middlewares, mw]); + return new CommandBuilder([...this.middlewares, mw], this.errorHandlers); + } + + onError(handler: ErrorHandler): CommandBuilder { + return new CommandBuilder(this.middlewares, [...this.errorHandlers, handler]); } command>>( @@ -157,13 +194,14 @@ export class CommandBuilder { options?: O; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler; run: ( interaction: ChatInputCommandInteraction, args: InferOptions, ctx: Ctx, ) => CommandResultType; }, - ): Command; + ): Command; command( name: string, @@ -176,12 +214,18 @@ export class CommandBuilder { command(name: string, config: any): any { if (config.run) { - return new Command(name, config, this.middlewares); + return new Command(name, config, this.middlewares, this.errorHandlers); } - return new SubcommandGroupBuilder(name, config.description, this.middlewares, { - integrationTypes: config.integrationTypes, - contexts: config.contexts, - }); + return new SubcommandGroupBuilder( + name, + config.description, + this.middlewares, + this.errorHandlers, + { + integrationTypes: config.integrationTypes, + contexts: config.contexts, + }, + ); } userContextMenu( @@ -191,16 +235,32 @@ export class CommandBuilder { | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler; run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; }, - ): ContextMenuCommand { - if (typeof runOrConfig === "function") - return new ContextMenuCommand(name, "user", runOrConfig, this.middlewares); + ): ContextMenuCommand { + if (typeof runOrConfig === "function") { + return new ContextMenuCommand( + name, + "user", + runOrConfig, + this.middlewares, + this.errorHandlers, + ); + } - return new ContextMenuCommand(name, "user", runOrConfig.run, this.middlewares, { - integrationTypes: runOrConfig.integrationTypes, - contexts: runOrConfig.contexts, - }); + return new ContextMenuCommand( + name, + "user", + runOrConfig.run, + this.middlewares, + this.errorHandlers, + { + integrationTypes: runOrConfig.integrationTypes, + contexts: runOrConfig.contexts, + onError: runOrConfig.onError, + }, + ); } messageContextMenu( @@ -212,33 +272,56 @@ export class CommandBuilder { ) => Promise | unknown) | { integrationTypes?: IntegrationType[]; - contexts?: IntegrationType[]; + contexts?: InteractionContextType[]; + onError?: ErrorHandler; run: (interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; }, - ): ContextMenuCommand { - if (typeof runOrConfig === "function") - return new ContextMenuCommand(name, "message", runOrConfig, this.middlewares); + ): ContextMenuCommand { + if (typeof runOrConfig === "function") { + return new ContextMenuCommand( + name, + "message", + runOrConfig, + this.middlewares, + this.errorHandlers, + ); + } - return new ContextMenuCommand(name, "message", runOrConfig.run, this.middlewares, { - integrationTypes: runOrConfig.integrationTypes, - contexts: runOrConfig.contexts, - }); + return new ContextMenuCommand( + name, + "message", + runOrConfig.run, + this.middlewares, + this.errorHandlers, + { + integrationTypes: runOrConfig.integrationTypes, + contexts: runOrConfig.contexts, + onError: runOrConfig.onError, + }, + ); } button>>( name: string, config: { options?: O; + onError?: ErrorHandler; run: (interaction: ButtonInteraction, args: InferOptions, ctx: Ctx) => CommandResultType; }, ): KittenComponent { - return new KittenComponent(name, config, this.middlewares); + return new KittenComponent( + name, + config, + this.middlewares, + this.errorHandlers, + ); } selectMenu>>( name: string, config: { options?: O; + onError?: ErrorHandler; run: ( interaction: AnySelectMenuInteraction, args: InferOptions, @@ -246,13 +329,19 @@ export class CommandBuilder { ) => CommandResultType; }, ): KittenComponent { - return new KittenComponent(name, config, this.middlewares); + return new KittenComponent( + name, + config, + this.middlewares, + this.errorHandlers, + ); } modal>>( name: string, config: { options?: O; + onError?: ErrorHandler; run: ( interaction: ModalSubmitInteraction, args: InferOptions, @@ -260,6 +349,11 @@ export class CommandBuilder { ) => CommandResultType; }, ): KittenComponent { - return new KittenComponent(name, config, this.middlewares); + return new KittenComponent( + name, + config, + this.middlewares, + this.errorHandlers, + ); } } diff --git a/pkgs/router/src/components.ts b/pkgs/router/src/components.ts index 6e687b1..ef33250 100644 --- a/pkgs/router/src/components.ts +++ b/pkgs/router/src/components.ts @@ -4,6 +4,7 @@ import type { ModalSubmitInteraction, } from "discord.js"; import type { Option, InferOptions } from "./options"; +import type { Middleware, ErrorHandler } from "./commands"; import { CustomIdTooLong } from "./errors"; export type KittenComponentInteraction = @@ -22,9 +23,11 @@ export class KittenComponent< public name: string, public config: { options?: Record>; - run: (interaction: I, args: any, ctx: Ctx) => Promise | unknown; + onError?: ErrorHandler; + run: (interaction: I, args: InferOptions, ctx: Ctx) => Promise | unknown; }, - public middlewares: Function[] = [], + public middlewares: Middleware[] = [], + public errorHandlers: ErrorHandler[] = [], ) {} /** @@ -43,7 +46,7 @@ export class KittenComponent< return customId; } - parseArgs(customId: string): Record { + parseArgs(customId: string): InferOptions { const [, ...parts] = customId.split(":"); const keys = Object.keys(this.config.options || {}); const parsed: Record = {}; @@ -60,6 +63,6 @@ export class KittenComponent< else parsed[key] = val === "" ? undefined : val; }); - return parsed; + return parsed as InferOptions; } } diff --git a/pkgs/router/src/kitten.ts b/pkgs/router/src/kitten.ts index 9d5bb51..12f04f6 100644 --- a/pkgs/router/src/kitten.ts +++ b/pkgs/router/src/kitten.ts @@ -24,20 +24,24 @@ import { ContextMenuCommand, type IntegrationType, type InteractionContextType, - CommandResultType, + type CommandResultType, + type Middleware, + type ErrorHandler, } from "./commands"; import { baseLogger, type KittenLogger } from "./logger"; export interface KittenOptions { logger?: KittenLogger; + onError?: ErrorHandler; } export class Kitten { - private commands = new Map>(); - private userContextMenus = new Map(); - private messageContextMenus = new Map(); + private commands = new Map | SubcommandGroupBuilder>(); + private userContextMenus = new Map>(); + private messageContextMenus = new Map>(); private components = new Map>(); private logger: KittenLogger; + private globalOnError?: ErrorHandler; constructor( private client: Client, @@ -45,6 +49,7 @@ export class Kitten { ) { this.client.on("interactionCreate", this.handleInteraction.bind(this)); this.logger = options.logger ?? baseLogger; + this.globalOnError = options.onError; this.logger.debug("kitten instance initialized."); } @@ -52,6 +57,11 @@ export class Kitten { return new CommandBuilder(); } + onError(handler: ErrorHandler) { + this.globalOnError = handler; + return this; + } + command>>( name: string, config: { @@ -59,9 +69,10 @@ export class Kitten { options?: O; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler; run: (interaction: ChatInputCommandInteraction, args: InferOptions) => CommandResultType; }, - ): Command; + ): Command<{}>; command( name: string, @@ -79,26 +90,28 @@ export class Kitten { userContextMenu( name: string, runOrConfig: - | ((interaction: UserContextMenuCommandInteraction) => Promise | any) + | ((interaction: UserContextMenuCommandInteraction) => CommandResultType) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: (interaction: UserContextMenuCommandInteraction) => Promise | any; + onError?: ErrorHandler; + run: (interaction: UserContextMenuCommandInteraction) => CommandResultType; }, - ): ContextMenuCommand { + ): ContextMenuCommand<{}, UserContextMenuCommandInteraction> { return this.builder().userContextMenu(name, runOrConfig as any); } messageContextMenu( name: string, runOrConfig: - | ((interaction: MessageContextMenuCommandInteraction) => Promise | any) + | ((interaction: MessageContextMenuCommandInteraction) => CommandResultType) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: (interaction: MessageContextMenuCommandInteraction) => Promise | any; + onError?: ErrorHandler; + run: (interaction: MessageContextMenuCommandInteraction) => CommandResultType; }, - ): ContextMenuCommand { + ): ContextMenuCommand<{}, MessageContextMenuCommandInteraction> { return this.builder().messageContextMenu(name, runOrConfig as any); } @@ -106,7 +119,8 @@ export class Kitten { name: string, config: { options?: O; - run: (interaction: ButtonInteraction, args: InferOptions) => Promise | any; + onError?: ErrorHandler; + run: (interaction: ButtonInteraction, args: InferOptions) => CommandResultType; }, ): KittenComponent { return this.builder().button(name, config); @@ -116,7 +130,8 @@ export class Kitten { name: string, config: { options?: O; - run: (interaction: AnySelectMenuInteraction, args: InferOptions) => Promise | any; + onError?: ErrorHandler; + run: (interaction: AnySelectMenuInteraction, args: InferOptions) => CommandResultType; }, ): KittenComponent { return this.builder().selectMenu(name, config); @@ -126,15 +141,16 @@ export class Kitten { name: string, config: { options?: O; - run: (interaction: ModalSubmitInteraction, args: InferOptions) => Promise | any; + onError?: ErrorHandler; + run: (interaction: ModalSubmitInteraction, args: InferOptions) => CommandResultType; }, ): KittenComponent { return this.builder().modal(name, config); } register(registry: { - commands?: (Command | SubcommandGroupBuilder | ContextMenuCommand)[]; - components?: KittenComponent[]; + commands?: (Command | SubcommandGroupBuilder | ContextMenuCommand)[]; + components?: KittenComponent[]; }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) { @@ -219,6 +235,11 @@ export class Kitten { entry.middlewares, subcommand.config.run, subcommand.config.options, + undefined, + { + localOnError: subcommand.config.onError, + builderErrorHandlers: entry.errorHandlers, + }, ); } else if (subName) { const subcommand = entry.subcommands[subName]; @@ -234,6 +255,11 @@ export class Kitten { entry.middlewares, subcommand.config.run, subcommand.config.options, + undefined, + { + localOnError: subcommand.config.onError, + builderErrorHandlers: entry.errorHandlers, + }, ); } } else { @@ -243,6 +269,11 @@ export class Kitten { entry.middlewares, entry.config.run, entry.config.options, + undefined, + { + localOnError: entry.config.onError, + builderErrorHandlers: entry.errorHandlers, + }, ); } } @@ -264,19 +295,32 @@ export class Kitten { } this.logger.debug(`routing context menu command: "${interaction.commandName}" (${type})`); - await this.executeWithMiddlewares(interaction, entry.middlewares, entry.run); + await this.executeWithMiddlewares( + interaction, + entry.middlewares, + entry.run, + undefined, + undefined, + { + localOnError: entry.config?.onError, + builderErrorHandlers: entry.errorHandlers, + }, + ); } private async executeWithMiddlewares( interaction: BaseInteraction, - middlewares: Function[], + middlewares: Middleware[], run: Function, optionsSchema?: Record>, componentArgs?: Record, + extra?: { + localOnError?: ErrorHandler; + builderErrorHandlers?: ErrorHandler[]; + }, ) { + let context: Record = {}; try { - let context = {}; - this.logger.debug( `Running ${middlewares.length} middleware(s) for interaction ${interaction.id}`, ); @@ -325,27 +369,74 @@ export class Kitten { return; } - const isCommand = "commandName" in interaction; - const identifier = isCommand - ? (interaction as any).commandName - : "customId" in interaction - ? (interaction as any).customId - : "unknown"; - const typeLabel = isCommand ? "command" : "component"; - - this.logger.error(`unhandled error executing ${typeLabel} ${identifier}:`, { - error: err instanceof Error ? err.stack : String(err), - [typeLabel]: identifier, - user: interaction.user.id, - }); + let activeError = err; + let handled = false; + + // 1. Try local error handler first + try { + if (extra?.localOnError) { + this.logger.debug(`executing local error handler for interaction ${interaction.id}`); + await extra.localOnError(activeError, interaction, context); + handled = true; + } + } catch (localErr) { + this.logger.debug(`local error handler threw an error, propagating...`); + activeError = localErr; + } + + // 2. Fallback to builder-level error handlers + if (!handled && extra?.builderErrorHandlers) { + for (const handler of extra.builderErrorHandlers) { + try { + this.logger.debug(`executing builder error handler for interaction ${interaction.id}`); + await handler(activeError, interaction, context); + handled = true; + break; + } catch (builderErr) { + this.logger.debug( + `builder error handler threw an error, propagating to next handler...`, + ); + activeError = builderErr; + } + } + } + + if (!handled && this.globalOnError) { + try { + this.logger.debug(`executing global error handler for interaction ${interaction.id}`); + await this.globalOnError(activeError, interaction, context); + handled = true; + } catch (globalErr) { + this.logger.debug( + `global error handler threw an error, falling back to default logging.`, + ); + activeError = globalErr; + } + } - if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { - await interaction - .reply({ - content: `an error occurred while executing this ${typeLabel}.`, - ephemeral: true, - }) - .catch(() => null); + if (!handled) { + const isCommand = "commandName" in interaction; + const identifier = isCommand + ? (interaction as any).commandName + : "customId" in interaction + ? (interaction as any).customId + : "unknown"; + const typeLabel = isCommand ? "command" : "component"; + + this.logger.error(`unhandled error executing ${typeLabel} ${identifier}:`, { + error: activeError instanceof Error ? activeError.stack : String(activeError), + [typeLabel]: identifier, + user: interaction.user.id, + }); + + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { + await interaction + .reply({ + content: `An error occurred while executing this ${typeLabel}.`, + flags: ["Ephemeral"], + }) + .catch(() => null); + } } } } @@ -414,6 +505,10 @@ export class Kitten { component.config.run, undefined, args, + { + localOnError: component.config.onError, + builderErrorHandlers: component.errorHandlers, + }, ); } diff --git a/pkgs/router/src/options.ts b/pkgs/router/src/options.ts index ba7a209..61093a6 100644 --- a/pkgs/router/src/options.ts +++ b/pkgs/router/src/options.ts @@ -90,6 +90,6 @@ export const option = { user: createOption("user"), channel: createOption("channel"), role: createOption("role"), - mentionable: createOption("mentionable"), + mentionable: createOption("mentionable"), attachment: createOption("attachment"), };