From c833d7ad93515d69a50a7ae7a3f60493258e82bb Mon Sep 17 00:00:00 2001 From: nethris Date: Sun, 26 Oct 2025 13:00:57 +0100 Subject: [PATCH 1/5] feat: add subcommands and subcommand groups handling --- .../harmonix/src/discord/builders/slash.ts | 74 +++++++++++++++---- .../harmonix/src/discord/handlers/command.ts | 41 +++++++++- packages/harmonix/src/runtime/index.ts | 2 + .../harmonix/src/runtime/internal/command.ts | 69 ++++++++++++++--- packages/harmonix/src/types/index.ts | 1 - packages/harmonix/src/types/module.ts | 44 ++++++++++- .../harmonix/src/types/runtime/command.ts | 37 +++++++++- 7 files changed, 236 insertions(+), 32 deletions(-) diff --git a/packages/harmonix/src/discord/builders/slash.ts b/packages/harmonix/src/discord/builders/slash.ts index 7d90003..ec2c421 100644 --- a/packages/harmonix/src/discord/builders/slash.ts +++ b/packages/harmonix/src/discord/builders/slash.ts @@ -2,12 +2,17 @@ import { ChannelType, InteractionContextType, PermissionFlagsBits, - SlashCommandBuilder + SlashCommandBuilder, + SlashCommandSubcommandBuilder } from 'discord.js' import { toArray } from '../../utils/helpers' -import type { HarmonixSlashCommand } from '../../types/module' +import type { + HarmonixSlashCommand, + HarmonixSlashCommandWithOptions, + HarmonixSlashCommandWithSubs +} from '../../types/module' import type { AttachmentOption, BooleanOption, @@ -44,12 +49,55 @@ export const buildSlashCommand = (command: HarmonixSlashCommand) => { builder.setContexts(contexts) } - addOptions(builder, command.options) + if ('subcommands' in command && command.subcommands) { + for (const [name, subOrGroup] of Object.entries(command.subcommands)) { + if ('subcommands' in subOrGroup) { + builder.addSubcommandGroup((groupBuilder) => { + groupBuilder + .setName(subOrGroup.name ?? name) + .setDescription(subOrGroup.description) + + for (const [subName, sub] of Object.entries(subOrGroup.subcommands)) { + groupBuilder.addSubcommand((subBuilder) => { + subBuilder + .setName(sub.name ?? subName) + .setDescription(sub.description) + + if (sub.options) { + addOptions(subBuilder, sub.options) + } + + return subBuilder + }) + } + + return groupBuilder + }) + } else { + builder.addSubcommand((subBuilder) => { + subBuilder + .setName(subOrGroup.name ?? name) + .setDescription(subOrGroup.description) + + if (subOrGroup.options) { + addOptions(subBuilder, subOrGroup.options) + } + + return subBuilder + }) + } + } + } else if ('options' in command && command.options) { + addOptions(builder, command.options) + } return builder.toJSON() } -const addOptions = (builder: SlashCommandBuilder, options: SlashOptionMap) => { +const addOptions = ( + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, + options: SlashOptionMap +) => { for (const [name, option] of Object.entries(options)) { switch (option.type) { case 'String': { @@ -93,7 +141,7 @@ const addOptions = (builder: SlashCommandBuilder, options: SlashOptionMap) => { } const addStringOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: StringOption ) => { @@ -112,7 +160,7 @@ const addStringOption = ( } const addIntegerOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: IntegerOption ) => { @@ -131,7 +179,7 @@ const addIntegerOption = ( } const addNumberOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: NumberOption ) => { @@ -150,7 +198,7 @@ const addNumberOption = ( } const addBooleanOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: BooleanOption ) => { @@ -164,7 +212,7 @@ const addBooleanOption = ( } const addChannelOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: ChannelOption ) => { @@ -186,7 +234,7 @@ const addChannelOption = ( } const addUserOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: UserOption ) => { @@ -200,7 +248,7 @@ const addUserOption = ( } const addRoleOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: RoleOption ) => { @@ -214,7 +262,7 @@ const addRoleOption = ( } const addMentionableOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: MentionableOption ) => { @@ -228,7 +276,7 @@ const addMentionableOption = ( } const addAttachmentOption = ( - builder: SlashCommandBuilder, + builder: SlashCommandBuilder | SlashCommandSubcommandBuilder, name: string, option: AttachmentOption ) => { diff --git a/packages/harmonix/src/discord/handlers/command.ts b/packages/harmonix/src/discord/handlers/command.ts index cd90272..febc83f 100644 --- a/packages/harmonix/src/discord/handlers/command.ts +++ b/packages/harmonix/src/discord/handlers/command.ts @@ -26,11 +26,44 @@ export const handleCommandInteraction = async ( case CommandType.Slash: { if (!interaction.isChatInputCommand()) break - const options = parseSlashOptions(interaction, command.options) + if ('subcommands' in command) { + const groupName = interaction.options.getSubcommandGroup(false) + const subName = interaction.options.getSubcommand(false) - await runPipeline(interaction, command.middleware, async (i) => - command.handler(i as any, options) - ) + if (groupName && subName) { + const group = command.subcommands[groupName] + + if (!group || !('subcommands' in group)) break + const sub = group.subcommands[subName] + + if (!sub) break + + const options = parseSlashOptions(interaction, sub.options ?? {}) + await runPipeline(interaction, command.middleware, async (i) => + sub.handler(i as any, options) + ) + } else if (subName) { + const sub = command.subcommands[subName] + + if (!sub || 'subcommands' in sub) break + const options = parseSlashOptions(interaction, sub.options ?? {}) + + await runPipeline(interaction, command.middleware, async (i) => + sub.handler(i as any, options) + ) + } + + break + } + + if ('options' in command && 'handler' in command) { + const options = parseSlashOptions(interaction, command.options ?? {}) + + await runPipeline(interaction, command.middleware, async (i) => + command.handler(i as any, options) + ) + break + } break } diff --git a/packages/harmonix/src/runtime/index.ts b/packages/harmonix/src/runtime/index.ts index ecb9a9b..8a89af7 100644 --- a/packages/harmonix/src/runtime/index.ts +++ b/packages/harmonix/src/runtime/index.ts @@ -1,6 +1,8 @@ // Commands export { defineSlashCommand, + defineSlashSubcommand, + defineSlashSubcommandGroup, defineUserContextMenuCommand, defineMessageContextMenuCommand } from './internal/command' diff --git a/packages/harmonix/src/runtime/internal/command.ts b/packages/harmonix/src/runtime/internal/command.ts index d00e170..d0d1148 100644 --- a/packages/harmonix/src/runtime/internal/command.ts +++ b/packages/harmonix/src/runtime/internal/command.ts @@ -2,36 +2,87 @@ import { CommandType, ModuleType } from '../../types/module' import type { HarmonixMessageContextMenuCommand, - HarmonixSlashCommand, + HarmonixSlashCommandWithOptions, + HarmonixSlashCommandWithSubs, + HarmonixSlashSubcommand, + HarmonixSlashSubcommandGroup, HarmonixUserContextMenuCommand } from '../../types/module' import type { HarmonixContextMenuConfig, HarmonixMessageContextMenuCommandHandler, - HarmonixSlashCommandConfig, + HarmonixSlashCommandConfigBase, + HarmonixSlashCommandConfigWithOptions, + HarmonixSlashCommandConfigWithSubs, HarmonixSlashCommandHandler, + HarmonixSlashSubcommandConfig, + HarmonixSlashSubcommandGroupConfig, HarmonixUserContextMenuCommandHandler } from '../../types/runtime/command' import type { SlashOptionMap } from '../../types/runtime/options' -export const defineSlashCommand = ( - config: HarmonixSlashCommandConfig, +export function defineSlashCommand( + config: HarmonixSlashCommandConfigWithOptions, handler: HarmonixSlashCommandHandler -) => { +): HarmonixSlashCommandWithOptions + +export function defineSlashCommand< + Subs extends Record< + string, + HarmonixSlashSubcommand | HarmonixSlashSubcommandGroup + > +>( + config: HarmonixSlashCommandConfigWithSubs +): HarmonixSlashCommandWithSubs + +export function defineSlashCommand( + config: HarmonixSlashCommandConfigBase, + handler: HarmonixSlashCommandHandler +): HarmonixSlashCommandWithOptions + +export function defineSlashCommand( + config: any, + handler?: (...args: any[]) => any +): any { return { type: ModuleType.Command, + commandType: CommandType.Slash, name: config.name, category: config.category, description: config.description, contexts: config.contexts, memberPermissions: config.memberPermissions, nsfw: config.nsfw, - options: config.options ?? {}, - autocomplete: config.autocomplete, middleware: config.middleware ?? [], - commandType: CommandType.Slash, + ...(config.subcommands + ? { subcommands: config.subcommands } + : { options: config.options, autocomplete: config.autocomplete, handler }) + } +} + +export const defineSlashSubcommand = ( + config: HarmonixSlashSubcommandConfig, + handler: HarmonixSlashCommandHandler +): HarmonixSlashSubcommand => { + return { + name: config.name, + description: config.description, + options: config.options, + autocomplete: config.autocomplete, handler - } as HarmonixSlashCommand + } +} + +export function defineSlashSubcommandGroup< + Subs extends Record +>( + config: HarmonixSlashSubcommandGroupConfig +): HarmonixSlashSubcommandGroup { + return { + name: config.name, + description: config.description, + subcommands: config.subcommands + } } export function defineUserContextMenuCommand( diff --git a/packages/harmonix/src/types/index.ts b/packages/harmonix/src/types/index.ts index 7f736df..29656f4 100644 --- a/packages/harmonix/src/types/index.ts +++ b/packages/harmonix/src/types/index.ts @@ -7,7 +7,6 @@ export type { HarmonixPlugin } from './runtime/plugin' export type { ModuleType, HarmonixCommand, - HarmonixSlashCommand, HarmonixUserContextMenuCommand, HarmonixMessageContextMenuCommand, HarmonixEvent, diff --git a/packages/harmonix/src/types/module.ts b/packages/harmonix/src/types/module.ts index 6261e77..b2d2f31 100644 --- a/packages/harmonix/src/types/module.ts +++ b/packages/harmonix/src/types/module.ts @@ -89,12 +89,15 @@ export interface HarmonixComponent extends HarmonixModule { componentType: ComponentType } -export interface HarmonixSlashCommand< - Options extends SlashOptionMap = SlashOptionMap -> extends HarmonixCommand { +interface HarmonixSlashCommandBase extends HarmonixCommand { commandType: CommandType.Slash description: string nsfw?: boolean +} + +export interface HarmonixSlashCommandWithOptions< + Options extends SlashOptionMap = SlashOptionMap +> extends HarmonixSlashCommandBase { options: Options autocomplete?: SlashCommandAutocomplete handler: ( @@ -103,6 +106,41 @@ export interface HarmonixSlashCommand< ) => Awaitable } +export interface HarmonixSlashCommandWithSubs< + Subcommands extends Record< + string, + HarmonixSlashSubcommand | HarmonixSlashSubcommandGroup + > = Record +> extends HarmonixSlashCommandBase { + subcommands: Subcommands +} + +export interface HarmonixSlashSubcommand { + name?: string + description: string + options?: Options + autocomplete?: SlashCommandAutocomplete + handler: ( + interaction: ChatInputCommandInteraction, + options: ExtractOptions + ) => Awaitable +} + +export interface HarmonixSlashSubcommandGroup< + Subs extends Record = Record< + string, + HarmonixSlashSubcommand + > +> { + name?: string + description: string + subcommands: Subs +} + +export type HarmonixSlashCommand = + | HarmonixSlashCommandWithOptions + | HarmonixSlashCommandWithSubs + export interface HarmonixUserContextMenuCommand extends HarmonixCommand { commandType: CommandType.UserContextMenu interactionTypes?: OneOrMany diff --git a/packages/harmonix/src/types/runtime/command.ts b/packages/harmonix/src/types/runtime/command.ts index 72a742e..65ddf3d 100644 --- a/packages/harmonix/src/types/runtime/command.ts +++ b/packages/harmonix/src/types/runtime/command.ts @@ -11,17 +11,50 @@ import type { import type { Awaitable, MatchingKeys, OneOrMany } from '../utils' import type { ExtractOptions, SlashOptionMap } from './options' import type { Middleware } from './pipeline' +import type { + HarmonixSlashSubcommand, + HarmonixSlashSubcommandGroup +} from '../module' -export interface HarmonixSlashCommandConfig { +export interface HarmonixSlashCommandConfigBase { name?: string category?: string description: string contexts?: OneOrMany memberPermissions?: OneOrMany nsfw?: boolean + middleware?: Middleware[] +} + +export interface HarmonixSlashCommandConfigWithOptions< + Options extends SlashOptionMap +> extends HarmonixSlashCommandConfigBase { options?: Options autocomplete?: SlashCommandAutocomplete - middleware?: Middleware[] +} + +export interface HarmonixSlashCommandConfigWithSubs< + Subs extends Record< + string, + HarmonixSlashSubcommand | HarmonixSlashSubcommandGroup + > +> extends HarmonixSlashCommandConfigBase { + subcommands: Subs +} + +export interface HarmonixSlashSubcommandConfig { + name?: string + description: string + options?: Options + autocomplete?: SlashCommandAutocomplete +} + +export interface HarmonixSlashSubcommandGroupConfig< + Subs extends Record +> { + name?: string + description: string + subcommands: Subs } export type SlashCommandAutocomplete = { From e4c827fcfeeec41f37c35c328fe7b96ab51d34a6 Mon Sep 17 00:00:00 2001 From: nethris Date: Sun, 26 Oct 2025 13:53:52 +0100 Subject: [PATCH 2/5] chore: add subcommand usage --- playground/commands/info.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 playground/commands/info.ts diff --git a/playground/commands/info.ts b/playground/commands/info.ts new file mode 100644 index 0000000..af7a5b9 --- /dev/null +++ b/playground/commands/info.ts @@ -0,0 +1,30 @@ +import { defineSlashCommand, defineSlashSubcommand } from 'harmonix' + +const user = defineSlashSubcommand( + { + description: 'Get info about a user', + options: { + target: { + type: 'User', + description: 'The user to get info about', + required: false + } + } + }, + async (i, opts) => { + const user = opts.target ?? i.user + await i.reply(`👤 ${user.username}`) + } +) + +const server = defineSlashSubcommand( + { description: 'Get info about this server' }, + async (i) => { + await i.reply(`🏠 ${i.guild?.name}`) + } +) + +export default defineSlashCommand({ + description: 'Get information about something', + subcommands: { user, server } +}) From 7063f773c239b3fd0e3b9172d08fe74845b6c1c6 Mon Sep 17 00:00:00 2001 From: nethris Date: Sun, 26 Oct 2025 14:17:10 +0100 Subject: [PATCH 3/5] refactor: split handle slash command behavior --- .../harmonix/src/discord/handlers/command.ts | 98 +++++++++++-------- 1 file changed, 57 insertions(+), 41 deletions(-) diff --git a/packages/harmonix/src/discord/handlers/command.ts b/packages/harmonix/src/discord/handlers/command.ts index febc83f..20de18a 100644 --- a/packages/harmonix/src/discord/handlers/command.ts +++ b/packages/harmonix/src/discord/handlers/command.ts @@ -25,51 +25,12 @@ export const handleCommandInteraction = async ( switch (command.commandType) { case CommandType.Slash: { if (!interaction.isChatInputCommand()) break - - if ('subcommands' in command) { - const groupName = interaction.options.getSubcommandGroup(false) - const subName = interaction.options.getSubcommand(false) - - if (groupName && subName) { - const group = command.subcommands[groupName] - - if (!group || !('subcommands' in group)) break - const sub = group.subcommands[subName] - - if (!sub) break - - const options = parseSlashOptions(interaction, sub.options ?? {}) - await runPipeline(interaction, command.middleware, async (i) => - sub.handler(i as any, options) - ) - } else if (subName) { - const sub = command.subcommands[subName] - - if (!sub || 'subcommands' in sub) break - const options = parseSlashOptions(interaction, sub.options ?? {}) - - await runPipeline(interaction, command.middleware, async (i) => - sub.handler(i as any, options) - ) - } - - break - } - - if ('options' in command && 'handler' in command) { - const options = parseSlashOptions(interaction, command.options ?? {}) - - await runPipeline(interaction, command.middleware, async (i) => - command.handler(i as any, options) - ) - break - } + await handleSlashCommand(interaction, command) break } case CommandType.UserContextMenu: { if (!interaction.isUserContextMenuCommand()) break - await runPipeline(interaction, command.middleware, async (i) => command.handler(i as any) ) @@ -78,7 +39,6 @@ export const handleCommandInteraction = async ( case CommandType.MessageContextMenu: { if (!interaction.isMessageContextMenuCommand()) break - await runPipeline(interaction, command.middleware, async (i) => command.handler(i as any) ) @@ -87,6 +47,62 @@ export const handleCommandInteraction = async ( } } +export const handleSlashCommand = async ( + interaction: ChatInputCommandInteraction, + command: AnyCommand +) => { + if ('subcommands' in command) { + const target = resolveSubcomand(interaction, command) + + if (!target) return + const { sub, options } = target + + await runPipeline(interaction, command.middleware, async (i) => + sub.handler(i as any, options) + ) + + return + } + + if ('options' in command && 'handler' in command) { + const options = parseSlashOptions(interaction, command.options ?? {}) + + await runPipeline(interaction, command.middleware, async (i) => + command.handler(i as any, options) + ) + } +} + +const resolveSubcomand = ( + interaction: ChatInputCommandInteraction, + command: Extract +) => { + const groupName = interaction.options.getSubcommandGroup(false) + const subName = interaction.options.getSubcommand(false) + + if (!subName) return null + + if (groupName) { + const group = command.subcommands[groupName] + + if (!group || !('subcommands' in group)) return null + const sub = group.subcommands[subName] + + if (!sub) return null + + const options = parseSlashOptions(interaction, sub.options ?? {}) + + return { sub, options } + } + + const sub = command.subcommands[subName] + + if (!sub || 'subcommands' in sub) return null + const options = parseSlashOptions(interaction, sub.options ?? {}) + + return { sub, options } +} + const optionResolvers: Record< string, (i: ChatInputCommandInteraction, name: string) => unknown From ca74634365187c28cc9be4e0f054441e14720e4e Mon Sep 17 00:00:00 2001 From: nethris Date: Sun, 26 Oct 2025 14:19:39 +0100 Subject: [PATCH 4/5] chore: simplify `ping` command usage --- playground/commands/ping.ts | 43 ++++--------------------------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/playground/commands/ping.ts b/playground/commands/ping.ts index 9c867db..c77c858 100644 --- a/playground/commands/ping.ts +++ b/playground/commands/ping.ts @@ -1,43 +1,8 @@ -import { createMiddleware, defineSlashCommand } from 'harmonix' - -const withLogging = createMiddleware(async (interaction, next) => { - const t0 = Date.now() - - try { - await next() - } finally { - console.log( - `command ${interaction.commandName} finished in ${Date.now() - t0}ms` - ) - } -}) +import { defineSlashCommand } from 'harmonix' export default defineSlashCommand( - { - description: 'Ping command', - options: { - channel: { - type: 'Channel', - description: 'The channel to ping', - channelTypes: 'GuildText' - }, - ping: { - type: 'String', - description: 'The ping message', - autocomplete: true - } - }, - middleware: [withLogging] - }, - async (interaction, { channel }) => { - if (channel && channel.isSendable()) { - await channel.send('Pong! 🏓') - interaction.reply({ - content: `Pong! Message sent to ${channel}`, - flags: 'Ephemeral' - }) - } else { - interaction.reply('Pong! 🏓') - } + { description: 'Ping command' }, + async (interaction) => { + await interaction.reply('Pong! 🏓') } ) From 14127db8b947fe30ef2dfe3d02bef022a797d13c Mon Sep 17 00:00:00 2001 From: nethris Date: Sun, 26 Oct 2025 14:28:51 +0100 Subject: [PATCH 5/5] chore: add changeset --- .changeset/tasty-tires-switch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tasty-tires-switch.md diff --git a/.changeset/tasty-tires-switch.md b/.changeset/tasty-tires-switch.md new file mode 100644 index 0000000..22d2009 --- /dev/null +++ b/.changeset/tasty-tires-switch.md @@ -0,0 +1,5 @@ +--- +'harmonix': patch +--- + +Add subcommands and subcommand groups handling