all repos — kitten @ 8a511d99dc35b383139b6948bc1f4c265aff202c

router: error handling yay
vi did:web:vt3e.cat
Wed, 08 Jul 2026 22:44:20 +0100
commit

8a511d99dc35b383139b6948bc1f4c265aff202c

parent

fd9ac67596edb7febe209fdee1181f3f8e98c0ba

M bun.lockbun.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", },
M pkgs/router/GET-STARTED.mdpkgs/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 @@

- [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

@@ -85,6 +89,27 @@ value: option.string("the value", { required: true }),

}, async run(interaction, { key, value }) { await interaction.reply(`set ${key} to ${value}`); + }, +}); +``` + +## 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}"` }); }, }); ```

@@ -212,6 +237,85 @@ },

}); ``` +## 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 @@ `kitten.sync()`, otherwise they won't be registered with the Discord API.

```ts kitten.register({ - commands: [whoisCommand, configCommand, banCommand], - buttons: [closeTicketButton], + commands: [whoisCommand, configCommand, banCommand, reportUser, bookmarkMessage], + components: [closeTicketButton], }); ```
A 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
M pkgs/router/package.jsonpkgs/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",
M pkgs/router/src/commands.tspkgs/router/src/commands.ts

@@ -36,12 +36,24 @@ export type IntegrationType = (typeof INTEGRATION_TYPES)[keyof typeof INTEGRATION_TYPES];

export type InteractionContextType = (typeof INTERACTION_CONTEXTS)[keyof typeof INTERACTION_CONTEXTS]; +export type Middleware<I extends BaseInteraction = BaseInteraction, InCtx = any, OutCtx = any> = ( + interaction: I, + ctx: InCtx, +) => Promise<OutCtx | void> | OutCtx | void; + +export type ErrorHandler<I extends BaseInteraction = BaseInteraction, Ctx = any> = ( + err: unknown, + interaction: I, + ctx: Ctx, +) => Promise<unknown> | unknown; + export class Subcommand { constructor( public name: string, public config: { description: string; options?: Record<string, CommandOption<any, any>>; + onError?: ErrorHandler<ChatInputCommandInteraction, any>; run: Function; }, ) {}

@@ -60,6 +72,11 @@ name: string,

config: { description: string; options?: O; + onError?: ( + err: unknown, + interaction: ChatInputCommandInteraction, + ctx: Ctx, + ) => Promise<unknown> | unknown; run: ( interaction: ChatInputCommandInteraction, args: InferOptions<O>,

@@ -79,7 +96,8 @@

constructor( public name: string, public description: string, - public middlewares: Function[] = [], + public middlewares: Middleware<any, any, any>[] = [], + public errorHandlers: ErrorHandler<any, any>[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[];

@@ -91,6 +109,11 @@ name: string,

config: { description: string; options?: O; + onError?: ( + err: unknown, + interaction: ChatInputCommandInteraction, + ctx: Ctx, + ) => Promise<unknown> | unknown; run: ( interaction: ChatInputCommandInteraction, args: InferOptions<O>,

@@ -114,7 +137,7 @@ return this;

} } -export class Command { +export class Command<Ctx = any> { constructor( public name: string, public config: {

@@ -122,32 +145,46 @@ description: string;

options?: Record<string, CommandOption<any, any>>; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: Function; + onError?: ErrorHandler<ChatInputCommandInteraction, Ctx>; + run: (interaction: ChatInputCommandInteraction, args: any, ctx: Ctx) => CommandResultType; }, - public middlewares: Function[] = [], + public middlewares: Middleware<any, any, any>[] = [], + public errorHandlers: ErrorHandler<any, any>[] = [], ) {} } -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> | unknown, + public middlewares: Middleware<any, any, any>[] = [], + public errorHandlers: ErrorHandler<any, any>[] = [], public config?: { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler<I, Ctx>; }, ) {} } export class CommandBuilder<Ctx = {}> { - constructor(private middlewares: Function[] = []) {} + constructor( + private middlewares: Middleware<any, any, any>[] = [], + private errorHandlers: ErrorHandler<any, any>[] = [], + ) {} use<NewCtx extends Record<string, any>>( mw: (interaction: BaseInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, ): CommandBuilder<Ctx & NewCtx> { - return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw]); + return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw], this.errorHandlers); + } + + onError(handler: ErrorHandler<BaseInteraction, Ctx>): CommandBuilder<Ctx> { + return new CommandBuilder<Ctx>(this.middlewares, [...this.errorHandlers, handler]); } command<O extends Record<string, CommandOption<any, any>>>(

@@ -157,13 +194,14 @@ description: string;

options?: O; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler<ChatInputCommandInteraction, Ctx>; run: ( interaction: ChatInputCommandInteraction, args: InferOptions<O>, ctx: Ctx, ) => CommandResultType; }, - ): Command; + ): Command<Ctx>; command( name: string,

@@ -176,12 +214,18 @@ ): SubcommandGroupBuilder<Ctx>;

command(name: string, config: any): any { if (config.run) { - return new Command(name, config, this.middlewares); + return new Command<Ctx>(name, config, this.middlewares, this.errorHandlers); } - return new SubcommandGroupBuilder<Ctx>(name, config.description, this.middlewares, { - integrationTypes: config.integrationTypes, - contexts: config.contexts, - }); + return new SubcommandGroupBuilder<Ctx>( + name, + config.description, + this.middlewares, + this.errorHandlers, + { + integrationTypes: config.integrationTypes, + contexts: config.contexts, + }, + ); } userContextMenu(

@@ -191,16 +235,32 @@ | ((interaction: UserContextMenuCommandInteraction, ctx: Ctx) => Promise<unknown> | unknown)

| { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler<UserContextMenuCommandInteraction, Ctx>; run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; }, - ): ContextMenuCommand { - if (typeof runOrConfig === "function") - return new ContextMenuCommand(name, "user", runOrConfig, this.middlewares); + ): ContextMenuCommand<Ctx, UserContextMenuCommandInteraction> { + if (typeof runOrConfig === "function") { + return new ContextMenuCommand<Ctx, UserContextMenuCommandInteraction>( + 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<Ctx, UserContextMenuCommandInteraction>( + name, + "user", + runOrConfig.run, + this.middlewares, + this.errorHandlers, + { + integrationTypes: runOrConfig.integrationTypes, + contexts: runOrConfig.contexts, + onError: runOrConfig.onError, + }, + ); } messageContextMenu(

@@ -212,33 +272,56 @@ ctx: Ctx,

) => Promise<unknown> | unknown) | { integrationTypes?: IntegrationType[]; - contexts?: IntegrationType[]; + contexts?: InteractionContextType[]; + onError?: ErrorHandler<MessageContextMenuCommandInteraction, Ctx>; run: (interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => CommandResultType; }, - ): ContextMenuCommand { - if (typeof runOrConfig === "function") - return new ContextMenuCommand(name, "message", runOrConfig, this.middlewares); + ): ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction> { + if (typeof runOrConfig === "function") { + return new ContextMenuCommand<Ctx, MessageContextMenuCommandInteraction>( + 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<Ctx, MessageContextMenuCommandInteraction>( + name, + "message", + runOrConfig.run, + this.middlewares, + this.errorHandlers, + { + integrationTypes: runOrConfig.integrationTypes, + contexts: runOrConfig.contexts, + onError: runOrConfig.onError, + }, + ); } button<O extends Record<string, Option<any, any>>>( name: string, config: { options?: O; + onError?: ErrorHandler<ButtonInteraction, Ctx>; run: (interaction: ButtonInteraction, args: InferOptions<O>, ctx: Ctx) => CommandResultType; }, ): KittenComponent<ButtonInteraction, O, Ctx> { - return new KittenComponent<ButtonInteraction, O, Ctx>(name, config, this.middlewares); + return new KittenComponent<ButtonInteraction, O, Ctx>( + name, + config, + this.middlewares, + this.errorHandlers, + ); } selectMenu<O extends Record<string, Option<any, any>>>( name: string, config: { options?: O; + onError?: ErrorHandler<AnySelectMenuInteraction, Ctx>; run: ( interaction: AnySelectMenuInteraction, args: InferOptions<O>,

@@ -246,13 +329,19 @@ ctx: Ctx,

) => CommandResultType; }, ): KittenComponent<AnySelectMenuInteraction, O, Ctx> { - return new KittenComponent<AnySelectMenuInteraction, O, Ctx>(name, config, this.middlewares); + return new KittenComponent<AnySelectMenuInteraction, O, Ctx>( + name, + config, + this.middlewares, + this.errorHandlers, + ); } modal<O extends Record<string, Option<any, any>>>( name: string, config: { options?: O; + onError?: ErrorHandler<ModalSubmitInteraction, Ctx>; run: ( interaction: ModalSubmitInteraction, args: InferOptions<O>,

@@ -260,6 +349,11 @@ ctx: Ctx,

) => CommandResultType; }, ): KittenComponent<ModalSubmitInteraction, O, Ctx> { - return new KittenComponent<ModalSubmitInteraction, O, Ctx>(name, config, this.middlewares); + return new KittenComponent<ModalSubmitInteraction, O, Ctx>( + name, + config, + this.middlewares, + this.errorHandlers, + ); } }
M pkgs/router/src/components.tspkgs/router/src/components.ts

@@ -4,6 +4,7 @@ AnySelectMenuInteraction,

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 @@ constructor(

public name: string, public config: { options?: Record<string, Option<any, any>>; - run: (interaction: I, args: any, ctx: Ctx) => Promise<unknown> | unknown; + onError?: ErrorHandler<I, Ctx>; + run: (interaction: I, args: InferOptions<O>, ctx: Ctx) => Promise<unknown> | unknown; }, - public middlewares: Function[] = [], + public middlewares: Middleware<any, any, any>[] = [], + public errorHandlers: ErrorHandler<any, any>[] = [], ) {} /**

@@ -43,7 +46,7 @@

return customId; } - parseArgs(customId: string): Record<string, any> { + parseArgs(customId: string): InferOptions<O> { const [, ...parts] = customId.split(":"); const keys = Object.keys(this.config.options || {}); const parsed: Record<string, any> = {};

@@ -60,6 +63,6 @@ else if (opt?.type === "integer" || opt?.type === "number") parsed[key] = Number(val);

else parsed[key] = val === "" ? undefined : val; }); - return parsed; + return parsed as InferOptions<O>; } }
M pkgs/router/src/kitten.tspkgs/router/src/kitten.ts

@@ -24,20 +24,24 @@ SubcommandGroupBuilder,

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<BaseInteraction, any>; } export class Kitten { - private commands = new Map<string, Command | SubcommandGroupBuilder<any>>(); - private userContextMenus = new Map<string, ContextMenuCommand>(); - private messageContextMenus = new Map<string, ContextMenuCommand>(); + private commands = new Map<string, Command<any> | SubcommandGroupBuilder<any>>(); + private userContextMenus = new Map<string, ContextMenuCommand<any, any>>(); + private messageContextMenus = new Map<string, ContextMenuCommand<any, any>>(); private components = new Map<string, KittenComponent<any, any, any>>(); private logger: KittenLogger; + private globalOnError?: ErrorHandler<BaseInteraction, any>; constructor( private client: Client,

@@ -45,6 +49,7 @@ options: KittenOptions = {},

) { 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 @@ builder() {

return new CommandBuilder(); } + onError(handler: ErrorHandler<BaseInteraction, any>) { + this.globalOnError = handler; + return this; + } + command<O extends Record<string, CommandOption<any, any>>>( name: string, config: {

@@ -59,9 +69,10 @@ description: string;

options?: O; integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; + onError?: ErrorHandler<ChatInputCommandInteraction, {}>; run: (interaction: ChatInputCommandInteraction, args: InferOptions<O>) => CommandResultType; }, - ): Command; + ): Command<{}>; command( name: string,

@@ -79,26 +90,28 @@

userContextMenu( name: string, runOrConfig: - | ((interaction: UserContextMenuCommandInteraction) => Promise<any> | any) + | ((interaction: UserContextMenuCommandInteraction) => CommandResultType) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: (interaction: UserContextMenuCommandInteraction) => Promise<any> | any; + onError?: ErrorHandler<UserContextMenuCommandInteraction, {}>; + run: (interaction: UserContextMenuCommandInteraction) => CommandResultType; }, - ): ContextMenuCommand { + ): ContextMenuCommand<{}, UserContextMenuCommandInteraction> { return this.builder().userContextMenu(name, runOrConfig as any); } messageContextMenu( name: string, runOrConfig: - | ((interaction: MessageContextMenuCommandInteraction) => Promise<any> | any) + | ((interaction: MessageContextMenuCommandInteraction) => CommandResultType) | { integrationTypes?: IntegrationType[]; contexts?: InteractionContextType[]; - run: (interaction: MessageContextMenuCommandInteraction) => Promise<any> | any; + onError?: ErrorHandler<MessageContextMenuCommandInteraction, {}>; + run: (interaction: MessageContextMenuCommandInteraction) => CommandResultType; }, - ): ContextMenuCommand { + ): ContextMenuCommand<{}, MessageContextMenuCommandInteraction> { return this.builder().messageContextMenu(name, runOrConfig as any); }

@@ -106,7 +119,8 @@ button<O extends Record<string, Option<any, any>>>(

name: string, config: { options?: O; - run: (interaction: ButtonInteraction, args: InferOptions<O>) => Promise<any> | any; + onError?: ErrorHandler<ButtonInteraction, {}>; + run: (interaction: ButtonInteraction, args: InferOptions<O>) => CommandResultType; }, ): KittenComponent<ButtonInteraction, O, {}> { return this.builder().button(name, config);

@@ -116,7 +130,8 @@ selectMenu<O extends Record<string, Option<any, any>>>(

name: string, config: { options?: O; - run: (interaction: AnySelectMenuInteraction, args: InferOptions<O>) => Promise<any> | any; + onError?: ErrorHandler<AnySelectMenuInteraction, {}>; + run: (interaction: AnySelectMenuInteraction, args: InferOptions<O>) => CommandResultType; }, ): KittenComponent<AnySelectMenuInteraction, O, {}> { return this.builder().selectMenu(name, config);

@@ -126,15 +141,16 @@ modal<O extends Record<string, Option<any, any>>>(

name: string, config: { options?: O; - run: (interaction: ModalSubmitInteraction, args: InferOptions<O>) => Promise<any> | any; + onError?: ErrorHandler<ModalSubmitInteraction, {}>; + run: (interaction: ModalSubmitInteraction, args: InferOptions<O>) => CommandResultType; }, ): KittenComponent<ModalSubmitInteraction, O, {}> { return this.builder().modal(name, config); } register(registry: { - commands?: (Command | SubcommandGroupBuilder<any> | ContextMenuCommand)[]; - components?: KittenComponent<any, any>[]; + commands?: (Command<any> | SubcommandGroupBuilder<any> | ContextMenuCommand<any, any>)[]; + components?: KittenComponent<any, any, any>[]; }) { registry.commands?.forEach((cmd) => { if (cmd instanceof ContextMenuCommand) {

@@ -219,6 +235,11 @@ interaction,

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 @@ interaction,

entry.middlewares, subcommand.config.run, subcommand.config.options, + undefined, + { + localOnError: subcommand.config.onError, + builderErrorHandlers: entry.errorHandlers, + }, ); } } else {

@@ -243,6 +269,11 @@ interaction,

entry.middlewares, entry.config.run, entry.config.options, + undefined, + { + localOnError: entry.config.onError, + builderErrorHandlers: entry.errorHandlers, + }, ); } }

@@ -264,19 +295,32 @@ return;

} 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<any, any, any>[], run: Function, optionsSchema?: Record<string, Option<any, any>>, componentArgs?: Record<string, any>, + extra?: { + localOnError?: ErrorHandler<any, any>; + builderErrorHandlers?: ErrorHandler<any, any>[]; + }, ) { + let context: Record<string, any> = {}; try { - let context = {}; - this.logger.debug( `Running ${middlewares.length} middleware(s) for interaction ${interaction.id}`, );

@@ -325,27 +369,74 @@ }

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"; + 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; + } - this.logger.error(`unhandled error executing ${typeLabel} ${identifier}:`, { - error: err instanceof Error ? err.stack : String(err), - [typeLabel]: identifier, - user: interaction.user.id, - }); + // 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 (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { - await interaction - .reply({ - content: `an error occurred while executing this ${typeLabel}.`, - ephemeral: true, - }) - .catch(() => null); + 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 (!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 @@ component.middlewares,

component.config.run, undefined, args, + { + localOnError: component.config.onError, + builderErrorHandlers: component.errorHandlers, + }, ); }
M pkgs/router/src/options.tspkgs/router/src/options.ts

@@ -90,6 +90,6 @@ boolean: createOption<boolean>("boolean"),

user: createOption<User>("user"), channel: createOption<GuildBasedChannel>("channel"), role: createOption<Role>("role"), - mentionable: createOption<User | Role | any>("mentionable"), + mentionable: createOption<User | Role>("mentionable"), attachment: createOption<Attachment>("attachment"), };