all repos — kitten @ a8518eafa7b03c967acd7fcdb1192661c80bf151

support user/message context menus
vi did:web:vt3e.cat
Fri, 12 Jun 2026 03:45:55 +0100
commit

a8518eafa7b03c967acd7fcdb1192661c80bf151

parent

ec4ee6e742f7af41cc1d4ec1a7d2b057dcfbf1a0

3 files changed, 125 insertions(+), 25 deletions(-)

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

@@ -1,4 +1,9 @@

-import type { ChatInputCommandInteraction } from "discord.js"; +import type { + ChatInputCommandInteraction, + CommandInteraction, + UserContextMenuCommandInteraction, + MessageContextMenuCommandInteraction, +} from "discord.js"; import type { Option, InferOptions } from "./options"; export class Subcommand {

@@ -87,14 +92,20 @@ public middlewares: Function[] = [],

) {} } +export class ContextMenuCommand { + constructor( + public name: string, + public type: "user" | "message", + public run: Function, + public middlewares: Function[] = [], + ) {} +} + export class CommandBuilder<Ctx = {}> { constructor(private middlewares: Function[] = []) {} use<NewCtx extends Record<string, any>>( - mw: ( - interaction: ChatInputCommandInteraction, - ctx: Ctx, - ) => Promise<NewCtx | void> | NewCtx | void, + mw: (interaction: CommandInteraction, ctx: Ctx) => Promise<NewCtx | void> | NewCtx | void, ): CommandBuilder<Ctx & NewCtx> { return new CommandBuilder<Ctx & NewCtx>([...this.middlewares, mw]); }

@@ -119,5 +130,19 @@ if (config.run) {

return new Command(name, config, this.middlewares); } return new SubcommandGroupBuilder<Ctx>(name, config.description, this.middlewares); + } + + userContextMenu( + name: string, + run: (interaction: UserContextMenuCommandInteraction, ctx: Ctx) => Promise<void> | void, + ): ContextMenuCommand { + return new ContextMenuCommand(name, "user", run, this.middlewares); + } + + messageContextMenu( + name: string, + run: (interaction: MessageContextMenuCommandInteraction, ctx: Ctx) => Promise<void> | void, + ): ContextMenuCommand { + return new ContextMenuCommand(name, "message", run, this.middlewares); } }
M pkgs/core/src/index.tspkgs/core/src/index.ts

@@ -12,7 +12,7 @@ // - [x] select menus

// - [x] buttons // // commands -// - [ ] context menus (user, message) +// - [x] context menus (user, message) // - [x] slash commands // // other
M pkgs/core/src/kitten.tspkgs/core/src/kitten.ts

@@ -6,13 +6,17 @@ type ButtonInteraction,

type AnySelectMenuInteraction, type ModalSubmitInteraction, type ApplicationCommandNonOptionsData, + type CommandInteraction, + type UserContextMenuCommandInteraction, + type MessageContextMenuCommandInteraction, BaseInteraction, + ApplicationCommandType, } from "discord.js"; import { HaltExecution } from "./errors"; import { type Option, type InferOptions, OPTION_TYPES } from "./options"; import { KittenComponent, type KittenComponentInteraction } from "./components"; -import { Command, CommandBuilder, SubcommandGroupBuilder } from "./commands"; +import { Command, CommandBuilder, SubcommandGroupBuilder, ContextMenuCommand } from "./commands"; import { baseLogger, type KittenLogger } from "./logger"; export interface KittenOptions {

@@ -21,6 +25,8 @@ }

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 logger: KittenLogger;

@@ -54,6 +60,20 @@ command(name: string, config: any): any {

return this.builder().command(name, config); } + userContextMenu( + name: string, + run: (interaction: UserContextMenuCommandInteraction) => Promise<void> | void, + ): ContextMenuCommand { + return this.builder().userContextMenu(name, run); + } + + messageContextMenu( + name: string, + run: (interaction: MessageContextMenuCommandInteraction) => Promise<void> | void, + ): ContextMenuCommand { + return this.builder().messageContextMenu(name, run); + } + button<O extends Record<string, Option<any, any>>>( name: string, config: {

@@ -85,22 +105,40 @@ return new KittenComponent<ModalSubmitInteraction, O>(name, config);

} register(registry: { - commands?: (Command | SubcommandGroupBuilder<any>)[]; + commands?: (Command | SubcommandGroupBuilder<any> | ContextMenuCommand)[]; components?: KittenComponent<any, any>[]; }) { - registry.commands?.forEach((cmd) => this.commands.set(cmd.name, cmd)); + registry.commands?.forEach((cmd) => { + if (cmd instanceof ContextMenuCommand) { + if (cmd.type === "user") this.userContextMenus.set(cmd.name, cmd); + else if (cmd.type === "message") this.messageContextMenus.set(cmd.name, cmd); + } else { + this.commands.set(cmd.name, cmd); + } + }); registry.components?.forEach((comp) => this.components.set(comp.name, comp)); } private async handleInteraction(interaction: BaseInteraction) { - // commands - if (interaction.isChatInputCommand()) await this.routeCommand(interaction); - else if (interaction.isAutocomplete()) await this.routeAutocomplete(interaction); + 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"); - // components - 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"); + // components + case interaction.isButton(): + return await this.routeComponent(interaction, "button"); + case interaction.isAnySelectMenu(): + return await this.routeComponent(interaction, "select menu"); + case interaction.isModalSubmit(): + return await this.routeComponent(interaction, "modal"); + } } private async routeCommand(interaction: ChatInputCommandInteraction) {

@@ -144,8 +182,21 @@ );

} } + private async routeContextMenu( + interaction: UserContextMenuCommandInteraction | MessageContextMenuCommandInteraction, + type: "user" | "message", + ) { + const entry = + type === "user" + ? this.userContextMenus.get(interaction.commandName) + : this.messageContextMenus.get(interaction.commandName); + + if (!entry) return; + await this.executeWithMiddlewares(interaction, entry.middlewares, entry.run); + } + private async executeWithMiddlewares( - interaction: ChatInputCommandInteraction, + interaction: CommandInteraction, middlewares: Function[], run: Function, optionsSchema?: Record<string, Option<any, any>>,

@@ -157,8 +208,12 @@ const result = await mw(interaction, context);

if (result) context = { ...context, ...result }; } - const args = this.parseSlashOptions(interaction, optionsSchema); - await run(interaction, args, context); + if (interaction.isChatInputCommand()) { + const args = this.parseSlashOptions(interaction, optionsSchema); + await run(interaction, args, context); + } else { + await run(interaction, context); + } } catch (err) { // TODO)) custom error handlers if (err instanceof HaltExecution) return;

@@ -263,7 +318,10 @@ return parsed;

} async sync({ guildId }: { guildId?: string } = {}) { - const payloads = Array.from(this.commands.values()).map((cmd) => { + const payloads: any[] = []; + + // chat input commands + for (const cmd of this.commands.values()) { if (cmd instanceof SubcommandGroupBuilder) { const subcommandOptions = Object.values(cmd.subcommands).map((sub) => ({ type: OPTION_TYPES.subcommand,

@@ -284,19 +342,36 @@ options: this.transformOptionsForDiscord(sub.config.options),

})), })); - return { + payloads.push({ + type: ApplicationCommandType.ChatInput, name: cmd.name, description: cmd.description, options: [...subcommandOptions, ...groupOptions], - }; + }); } else { - return { + payloads.push({ + type: ApplicationCommandType.ChatInput, name: cmd.name, description: cmd.config.description, options: this.transformOptionsForDiscord(cmd.config.options), - }; + }); } - }); + } + + // context menus + for (const cmd of this.userContextMenus.values()) { + payloads.push({ + type: ApplicationCommandType.User, + name: cmd.name, + }); + } + + for (const cmd of this.messageContextMenus.values()) { + payloads.push({ + type: ApplicationCommandType.Message, + name: cmd.name, + }); + } if (!this.client.application) { throw new Error("client application not ready to sync. ensure you await client.login()");