all repos — kitten @ ec4ee6e742f7af41cc1d4ec1a7d2b057dcfbf1a0

support modals and select menus
vi did:web:vt3e.cat
Fri, 12 Jun 2026 03:36:23 +0100
commit

ec4ee6e742f7af41cc1d4ec1a7d2b057dcfbf1a0

parent

f80d8c5364cf8ed06845366587938faec60d32db

3 files changed, 55 insertions(+), 16 deletions(-)

jump to
M pkgs/core/src/components.tspkgs/core/src/components.ts

@@ -1,15 +1,27 @@

-import type { ButtonInteraction } from "discord.js"; -import type { Option } from "./options"; +import type { + ButtonInteraction, + AnySelectMenuInteraction, + ModalSubmitInteraction, +} from "discord.js"; +import type { Option, InferOptions } from "./options"; import { CustomIdTooLong } from "./errors"; -export class KittenComponent { +export type KittenComponentInteraction = + | ButtonInteraction + | AnySelectMenuInteraction + | ModalSubmitInteraction; + +export class KittenComponent< + I extends KittenComponentInteraction = KittenComponentInteraction, + O extends Record<string, Option<any, any>> = Record<string, Option<any, any>>, +> { type = "component" as const; constructor( public name: string, public config: { options?: Record<string, Option<any, any>>; - run: (interaction: ButtonInteraction, args: any) => Promise<void> | void; + run: (interaction: I, args: any) => Promise<void> | void; }, ) {}

@@ -17,9 +29,11 @@ /**

* @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: Record<string, any> = {}): string { + 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?.[key] ?? ""))); const customId = [this.name, ...values].join(":"); if (customId.length > 100) throw new CustomIdTooLong(this.name, customId.length);
M pkgs/core/src/index.tspkgs/core/src/index.ts

@@ -7,8 +7,8 @@ export * from "./logger";

// TODO)) // components -// - [ ] modals -// - [ ] select menus +// - [x] modals +// - [x] select menus // - [x] buttons // // commands
M pkgs/core/src/kitten.tspkgs/core/src/kitten.ts

@@ -3,13 +3,15 @@ type Client,

type ChatInputCommandInteraction, type AutocompleteInteraction, type ButtonInteraction, + type AnySelectMenuInteraction, + type ModalSubmitInteraction, type ApplicationCommandNonOptionsData, BaseInteraction, } from "discord.js"; import { HaltExecution } from "./errors"; import { type Option, type InferOptions, OPTION_TYPES } from "./options"; -import { KittenComponent } from "./components"; +import { KittenComponent, type KittenComponentInteraction } from "./components"; import { Command, CommandBuilder, SubcommandGroupBuilder } from "./commands"; import { baseLogger, type KittenLogger } from "./logger";

@@ -19,7 +21,7 @@ }

export class Kitten { private commands = new Map<string, Command | SubcommandGroupBuilder<any>>(); - private components = new Map<string, KittenComponent>(); + private components = new Map<string, KittenComponent<any, any>>(); private logger: KittenLogger; constructor(

@@ -47,6 +49,7 @@ },

): Command; command(name: string, config: { description: string }): SubcommandGroupBuilder<{}>; + command(name: string, config: any): any { return this.builder().command(name, config); }

@@ -57,13 +60,33 @@ config: {

options?: O; run: (interaction: ButtonInteraction, args: InferOptions<O>) => Promise<void> | void; }, - ): KittenComponent { - return new KittenComponent(name, config); + ): KittenComponent<ButtonInteraction, O> { + return new KittenComponent<ButtonInteraction, O>(name, config); + } + + selectMenu<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: (interaction: AnySelectMenuInteraction, args: InferOptions<O>) => Promise<void> | void; + }, + ): KittenComponent<AnySelectMenuInteraction, O> { + return new KittenComponent<AnySelectMenuInteraction, O>(name, config); + } + + modal<O extends Record<string, Option<any, any>>>( + name: string, + config: { + options?: O; + run: (interaction: ModalSubmitInteraction, args: InferOptions<O>) => Promise<void> | void; + }, + ): KittenComponent<ModalSubmitInteraction, O> { + return new KittenComponent<ModalSubmitInteraction, O>(name, config); } register(registry: { commands?: (Command | SubcommandGroupBuilder<any>)[]; - components?: KittenComponent[]; + components?: KittenComponent<any, any>[]; }) { registry.commands?.forEach((cmd) => this.commands.set(cmd.name, cmd)); registry.components?.forEach((comp) => this.components.set(comp.name, comp));

@@ -75,7 +98,9 @@ if (interaction.isChatInputCommand()) await this.routeCommand(interaction);

else if (interaction.isAutocomplete()) await this.routeAutocomplete(interaction); // components - else if (interaction.isButton()) await this.routeButton(interaction); + else if (interaction.isButton()) await this.routeComponent(interaction, "button"); + else if (interaction.isAnySelectMenu()) await this.routeComponent(interaction, "select menu"); + else if (interaction.isModalSubmit()) await this.routeComponent(interaction, "modal"); } private async routeCommand(interaction: ChatInputCommandInteraction) {

@@ -177,7 +202,7 @@ await interaction.respond(choices).catch(() => null);

} } - private async routeButton(interaction: ButtonInteraction) { + private async routeComponent(interaction: KittenComponentInteraction, typeName: string) { const [prefix] = interaction.customId.split(":"); if (!prefix) return;

@@ -188,7 +213,7 @@ try {

const args = component.parseArgs(interaction.customId); await component.config.run(interaction, args); } catch (err) { - this.logger.error(`error executing button ${prefix}:`, { + this.logger.error(`error executing ${typeName} ${prefix}:`, { error: err instanceof Error ? err.stack : String(err), component: prefix, user: interaction.user.id,