router: middleware for buttons & other components
vi did:web:vt3e.cat
Fri, 26 Jun 2026 20:09:06 +0100
4 files changed,
97 insertions(+),
33 deletions(-)
M
pkgs/router/package.json
→
pkgs/router/package.json
@@ -1,6 +1,6 @@
{ "name": "@purrkit/router", - "version": "1.2.0", + "version": "1.3.0", "license": "EUPL-1.2", "author": { "name": "apr",
M
pkgs/router/src/commands.ts
→
pkgs/router/src/commands.ts
@@ -1,10 +1,14 @@
import type { ChatInputCommandInteraction, - CommandInteraction, UserContextMenuCommandInteraction, MessageContextMenuCommandInteraction, + BaseInteraction, + ButtonInteraction, + AnySelectMenuInteraction, + ModalSubmitInteraction, } from "discord.js"; -import type { CommandOption, InferOptions } from "./options"; +import type { CommandOption, Option, InferOptions } from "./options"; +import { KittenComponent } from "./components"; export const INTEGRATION_TYPES = { GUILD_INSTALL: 0,@@ -141,7 +145,7 @@ export class CommandBuilder<Ctx = {}> {
constructor(private middlewares: Function[] = []) {} use<NewCtx extends Record<string, any>>( - mw: (interaction: CommandInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, + mw: (interaction: BaseInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, ): CommandBuilder<Ctx & NewCtx> { return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw]); }@@ -219,5 +223,43 @@ return new ContextMenuCommand(name, "message", runOrConfig.run, this.middlewares, {
integrationTypes: runOrConfig.integrationTypes, contexts: runOrConfig.contexts, }); + } + + button<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: (interaction: ButtonInteraction, args: InferOptions<O>, ctx: Ctx) => CommandResultType; + }, + ): KittenComponent<ButtonInteraction, O, Ctx> { + return new KittenComponent<ButtonInteraction, O, Ctx>(name, config, this.middlewares); + } + + selectMenu<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: ( + interaction: AnySelectMenuInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => CommandResultType; + }, + ): KittenComponent<AnySelectMenuInteraction, O, Ctx> { + return new KittenComponent<AnySelectMenuInteraction, O, Ctx>(name, config, this.middlewares); + } + + modal<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: ( + interaction: ModalSubmitInteraction, + args: InferOptions<O>, + ctx: Ctx, + ) => CommandResultType; + }, + ): KittenComponent<ModalSubmitInteraction, O, Ctx> { + return new KittenComponent<ModalSubmitInteraction, O, Ctx>(name, config, this.middlewares); } }
M
pkgs/router/src/components.ts
→
pkgs/router/src/components.ts
@@ -14,6 +14,7 @@
export class KittenComponent< I extends KittenComponentInteraction = KittenComponentInteraction, O extends Record<string, Option<any, any>> = Record<string, Option<any, any>>, + Ctx = any, > { type = "component" as const;@@ -21,8 +22,9 @@ constructor(
public name: string, public config: { options?: Record<string, Option<any, any>>; - run: (interaction: I, args: any) => Promise<unknown> | unknown; + run: (interaction: I, args: any, ctx: Ctx) => Promise<unknown> | unknown; }, + public middlewares: Function[] = [], ) {} /**
M
pkgs/router/src/kitten.ts
→
pkgs/router/src/kitten.ts
@@ -7,7 +7,6 @@ type AnySelectMenuInteraction,
type ModalSubmitInteraction, type APIApplicationCommandOption, type APIApplicationCommandBasicOption, - type CommandInteraction, type UserContextMenuCommandInteraction, type MessageContextMenuCommandInteraction, BaseInteraction,@@ -37,7 +36,7 @@ export class Kitten {
private commands = new Map<string, Command | SubcommandGroupBuilder<any>>(); private userContextMenus = new Map<string, ContextMenuCommand>(); private messageContextMenus = new Map<string, ContextMenuCommand>(); - private components = new Map<string, KittenComponent<any, any>>(); + private components = new Map<string, KittenComponent<any, any, any>>(); private logger: KittenLogger; constructor(@@ -108,8 +107,8 @@ config: {
options?: O; run: (interaction: ButtonInteraction, args: InferOptions<O>) => Promise<any> | any; }, - ): KittenComponent<ButtonInteraction, O> { - return new KittenComponent<ButtonInteraction, O>(name, config); + ): KittenComponent<ButtonInteraction, O, {}> { + return this.builder().button(name, config); } selectMenu<O extends Record<string, Option<any, any>>>(@@ -118,8 +117,8 @@ config: {
options?: O; run: (interaction: AnySelectMenuInteraction, args: InferOptions<O>) => Promise<any> | any; }, - ): KittenComponent<AnySelectMenuInteraction, O> { - return new KittenComponent<AnySelectMenuInteraction, O>(name, config); + ): KittenComponent<AnySelectMenuInteraction, O, {}> { + return this.builder().selectMenu(name, config); } modal<O extends Record<string, Option<any, any>>>(@@ -128,8 +127,8 @@ config: {
options?: O; run: (interaction: ModalSubmitInteraction, args: InferOptions<O>) => Promise<any> | any; }, - ): KittenComponent<ModalSubmitInteraction, O> { - return new KittenComponent<ModalSubmitInteraction, O>(name, config); + ): KittenComponent<ModalSubmitInteraction, O, {}> { + return this.builder().modal(name, config); } register(registry: {@@ -161,11 +160,11 @@ return await this.routeContextMenu(interaction, "message");
// components case interaction.isButton(): - return await this.routeComponent(interaction, "button"); + return await this.routeComponent(interaction); case interaction.isAnySelectMenu(): - return await this.routeComponent(interaction, "select menu"); + return await this.routeComponent(interaction); case interaction.isModalSubmit(): - return await this.routeComponent(interaction, "modal"); + return await this.routeComponent(interaction); } }@@ -224,10 +223,11 @@ await this.executeWithMiddlewares(interaction, entry.middlewares, entry.run);
} private async executeWithMiddlewares( - interaction: CommandInteraction, + interaction: BaseInteraction, middlewares: Function[], run: Function, optionsSchema?: Record<string, Option<any, any>>, + componentArgs?: Record<string, any>, ) { try { let context = {};@@ -239,12 +239,23 @@
if (interaction.isChatInputCommand()) { const args = this.parseSlashOptions(interaction, optionsSchema); await run(interaction, args, context); + } else if ( + interaction.isButton() || + interaction.isAnySelectMenu() || + interaction.isModalSubmit() + ) { + await run(interaction, componentArgs ?? {}, context); } else { await run(interaction, context); } } catch (err) { if (err instanceof HaltExecution) { - if (err.replyPayload && !interaction.replied && !interaction.deferred) { + if ( + err.replyPayload && + interaction.isRepliable() && + !interaction.replied && + !interaction.deferred + ) { const replyOptions = typeof err.replyPayload === "string" ? { content: err.replyPayload, ephemeral: true }@@ -254,15 +265,26 @@ }
return; } - this.logger.error(`unhandled error executing command ${interaction.commandName}:`, { + 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), - command: interaction.commandName, + [typeLabel]: identifier, user: interaction.user.id, }); - if (!interaction.replied && !interaction.deferred) { + if (interaction.isRepliable() && !interaction.replied && !interaction.deferred) { await interaction - .reply({ content: "an error occurred while executing this command.", ephemeral: true }) + .reply({ + content: `an error occurred while executing this ${typeLabel}.`, + ephemeral: true, + }) .catch(() => null); } }@@ -293,23 +315,21 @@ await interaction.respond(choices).catch(() => null);
} } - private async routeComponent(interaction: KittenComponentInteraction, typeName: string) { + private async routeComponent(interaction: KittenComponentInteraction) { const [prefix] = interaction.customId.split(":"); if (!prefix) return; const component = this.components.get(prefix); if (!component) return; - try { - const args = component.parseArgs(interaction.customId); - await component.config.run(interaction, args); - } catch (err) { - this.logger.error(`error executing ${typeName} ${prefix}:`, { - error: err instanceof Error ? err.stack : String(err), - component: prefix, - user: interaction.user.id, - }); - } + const args = component.parseArgs(interaction.customId); + await this.executeWithMiddlewares( + interaction, + component.middlewares, + component.config.run, + undefined, + args, + ); } private parseSlashOptions(