From 97d85d7fc96fdf03180725bc0e7fc2a4ae3cf260 Mon Sep 17 00:00:00 2001 From: a Date: Sat, 14 Jun 2025 18:54:24 -0500 Subject: [PATCH] noot --- .../discord/botevent/command_parser.spec.ts | 177 ++++++++++++++++++ ts/src/discord/botevent/command_parser.ts | 134 +++++++------ ts/src/workflows/discord.ts | 7 +- 3 files changed, 260 insertions(+), 58 deletions(-) diff --git a/ts/src/discord/botevent/command_parser.spec.ts b/ts/src/discord/botevent/command_parser.spec.ts index 5f41162..13a1245 100644 --- a/ts/src/discord/botevent/command_parser.spec.ts +++ b/ts/src/discord/botevent/command_parser.spec.ts @@ -63,6 +63,52 @@ const TEST_COMMANDS = [ }, ], }, + { + name: 'admin', + description: 'Admin commands', + type: ApplicationCommandTypes.ChatInput, + options: [ + { + name: 'user', + description: 'User management', + type: ApplicationCommandOptionTypes.SubCommandGroup, + options: [ + { + name: 'ban', + description: 'Ban a user', + type: ApplicationCommandOptionTypes.SubCommand, + options: [ + { + name: 'user', + description: 'User to ban', + type: ApplicationCommandOptionTypes.User, + required: true, + }, + { + name: 'reason', + description: 'Ban reason', + type: ApplicationCommandOptionTypes.String, + required: false, + }, + ], + }, + { + name: 'kick', + description: 'Kick a user', + type: ApplicationCommandOptionTypes.SubCommand, + options: [ + { + name: 'user', + description: 'User to kick', + type: ApplicationCommandOptionTypes.User, + required: true, + }, + ], + }, + ], + }, + ], + }, ] as const satisfies CreateApplicationCommand[] describe('createCommandHandler', () => { @@ -77,6 +123,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -95,6 +147,7 @@ describe('createCommandHandler', () => { it('should handle a simple command with optional arguments', async () => { const simpleHandler = vi.fn() const notFoundHandler = vi.fn() + let a: ExtractCommands const handler = createCommandHandler({ handler: { @@ -103,6 +156,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -132,6 +191,12 @@ describe('createCommandHandler', () => { list: listHandler, create: createHandler, }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -167,6 +232,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -191,6 +262,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -221,6 +298,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler, }) @@ -232,6 +315,55 @@ describe('createCommandHandler', () => { expect(notFoundHandler).toHaveBeenCalledTimes(2) }) + it('should handle subcommand groups recursively', async () => { + const banHandler = vi.fn() + const kickHandler = vi.fn() + const notFoundHandler = vi.fn() + + const handler = createCommandHandler({ + handler: { + simple: vi.fn(), + complex: { + list: vi.fn(), + create: vi.fn(), + }, + admin: { + user: { + ban: banHandler, + kick: kickHandler, + }, + }, + }, + notFoundHandler, + }) + + const interactionData: InteractionData = { + name: 'admin', + options: [ + { + name: 'user', + type: ApplicationCommandOptionTypes.SubCommandGroup, + options: [ + { + name: 'ban', + type: ApplicationCommandOptionTypes.SubCommand, + options: [ + { name: 'user', type: ApplicationCommandOptionTypes.User, value: '123456789' }, + { name: 'reason', type: ApplicationCommandOptionTypes.String, value: 'Spam' }, + ], + }, + ], + }, + ], + } + + await handler(interactionData) + + expect(banHandler).toHaveBeenCalledWith({ user: '123456789', reason: 'Spam' }) + expect(kickHandler).not.toHaveBeenCalled() + expect(notFoundHandler).not.toHaveBeenCalled() + }) + it('should handle commands without options', async () => { const simpleHandler = vi.fn() @@ -242,6 +374,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler: vi.fn(), }) @@ -266,6 +404,12 @@ describe('createCommandHandler', () => { list: vi.fn(), create: vi.fn(), }, + admin: { + user: { + ban: vi.fn(), + kick: vi.fn(), + }, + }, }, notFoundHandler: vi.fn(), }) @@ -313,6 +457,22 @@ describe('ExtractCommands type utility', () => { expect(enabled).toBeDefined() }, }, + admin: { + user: { + ban: async (args) => { + // TypeScript should know that args has user as required string and reason as optional string + const user: string = args.user + const reason: string | undefined = args.reason + expect(user).toBeDefined() + expect(reason).toBeDefined() + }, + kick: async (args) => { + // TypeScript should know that args has user as required string + const user: string = args.user + expect(user).toBeDefined() + }, + }, + }, } // This is just to satisfy TypeScript - the real test is that the above compiles @@ -347,6 +507,23 @@ describe('ExtractCommands type utility', () => { expect(isEnabled).toBeDefined() }, }, + admin: { + user: { + ban: async (args) => { + // This would error if args.user wasn't a string + const userId = args.user.toLowerCase() + // This would error if args.reason wasn't string | undefined + const reasonLength = args.reason?.length || 0 + expect(userId).toBeDefined() + expect(reasonLength).toBeDefined() + }, + kick: async (args) => { + // This would error if args.user wasn't a string + const userId = args.user.toLowerCase() + expect(userId).toBeDefined() + }, + }, + }, }, }) diff --git a/ts/src/discord/botevent/command_parser.ts b/ts/src/discord/botevent/command_parser.ts index c7d468c..b5a1009 100644 --- a/ts/src/discord/botevent/command_parser.ts +++ b/ts/src/discord/botevent/command_parser.ts @@ -1,4 +1,4 @@ -import { ApplicationCommandOptionTypes, type CreateApplicationCommand, type DiscordInteractionDataOption } from '@discordeno/types' +import { ApplicationCommandOptionTypes, DiscordApplicationCommandOption, type CreateApplicationCommand, type DiscordInteractionDataOption } from '@discordeno/types' import type { InteractionData } from '..' import type { SLASH_COMMANDS } from './slash_commands' @@ -16,10 +16,10 @@ type OptionTypeMap = { } // Helper type to get option by name -type GetOption = Options extends readonly any[] ? (Options[number] extends infer O ? (O extends { name: Name } ? O : never) : never) : never +type GetOption = Options extends readonly DiscordApplicationCommandOption[] ? (Options[number] extends infer O ? (O extends { name: Name } ? O : never) : never) : never // Extract the argument types from command options -export type ExtractArgs = { +export type ExtractArgs = { [K in Options[number]['name']]: GetOption extends { type: infer T; required?: infer R } ? T extends keyof OptionTypeMap ? R extends true @@ -30,55 +30,73 @@ export type ExtractArgs = { } // Handler function type that accepts typed arguments -type HandlerFunction = (args: Args) => Promise | void +type HandlerFunction> = (args: Args) => Promise | void -// Get subcommand by name -type GetSubcommand = Options extends readonly any[] + + +// Get subcommand or subcommand group by name +type GetSubcommandOrGroup = Options extends readonly DiscordApplicationCommandOption[] ? Options[number] extends infer O - ? O extends { name: Name; type: ApplicationCommandOptionTypes.SubCommand } + ? O extends { name: Name; type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup } ? O : never : never : never -// Check if all options are subcommands -type HasOnlySubcommands = Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand } ? true : false +// Check if all options are subcommands or subcommand groups +type HasOnlySubcommands = Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup } ? true : false -// Extract subcommand names from options -type SubcommandNames = Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand } +// Extract subcommand or subcommand group names from options +type SubcommandNames = Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup } ? N extends string ? N : never : never -// Type to extract subcommand handlers -export type SubcommandHandlers = { - [K in SubcommandNames]: GetSubcommand extends { options?: infer SubOpts } - ? SubOpts extends readonly any[] - ? HandlerFunction> - : HandlerFunction<{}> - : HandlerFunction<{}> +// Type to extract subcommand handlers (recursive for groups) +export type SubcommandHandlers = { + [K in SubcommandNames]: GetSubcommandOrGroup extends { type: infer T; options?: infer SubOpts } + ? T extends ApplicationCommandOptionTypes.SubCommandGroup + ? SubOpts extends readonly DiscordApplicationCommandOption[] + ? SubcommandHandlers + : never + : T extends ApplicationCommandOptionTypes.SubCommand + ? SubOpts extends readonly DiscordApplicationCommandOption[] + ? HandlerFunction> + : HandlerFunction> + : never + : never } // Get command by name from array type GetCommand = Commands[number] extends infer C ? (C extends { name: Name } ? C : never) : never // Main type to extract command handlers from slash commands -export type ExtractCommands = { +export type ExtractCommands = { [Name in T[number]['name']]: GetCommand extends { options?: infer Options } - ? Options extends readonly any[] + ? Options extends readonly DiscordApplicationCommandOption[] ? HasOnlySubcommands extends true ? SubcommandHandlers : HandlerFunction> - : HandlerFunction<{}> - : HandlerFunction<{}> + : HandlerFunction> + : HandlerFunction> +} + +// Type representing the possible output of ExtractCommands +export type CommandHandler = HandlerFunction | CommandHandlerMap +export type CommandHandlerMap = { + [key: string]: CommandHandler } // The actual command handler type based on SLASH_COMMANDS export type CommandHandlers = ExtractCommands -// Helper function to parse option values from interaction data -function parseOptions(options?: DiscordInteractionDataOption[]): Record { +// Helper function to parse option values from interaction data. there is sort of a hack here +// so the actual type we need to parse comes from the parsing of DiscordApplicationCommandOption +// but this is parsing from the DiscordInteractionDataOption type, which is just the generic data response. +// technically, at runtime, with the SLASH_COMMANDS object, it's possible to validate this struct, and produce a better type than any +// but that is... a lot of work, so we are just going to do this for now and leave validation for another day. +function parseOptions(options?: T): any { if (!options) return {} const args: Record = {} @@ -87,60 +105,66 @@ function parseOptions(options?: DiscordInteractionDataOption[]): Record +): Promise { + // If handler is a function, execute it with parsed options + if (typeof handler === 'function') { + const args = parseOptions(options) + await handler(args) + return + } + + // Handler is a map of subcommands/groups + const subcommand = options?.find( + (opt) => opt.type === ApplicationCommandOptionTypes.SubCommand || opt.type === ApplicationCommandOptionTypes.SubCommandGroup + ) + + if (!subcommand) { + await notFoundHandler({}) + return + } + + const subHandler = handler[subcommand.name] + if (!subHandler) { + await notFoundHandler({}) + return + } + + // Recursively handle the subcommand/group + await handleCommands(subHandler, subcommand.options, notFoundHandler) +} + // Helper function to create command handlers with type safety export function createCommandHandler({ handler, notFoundHandler, }: { + commands: T, handler: ExtractCommands - notFoundHandler: HandlerFunction<{}> + notFoundHandler: HandlerFunction<{path?: string}> }) { return async (data: InteractionData): Promise => { if (!data || !data.name) { await notFoundHandler({}) return } - const commandName = data.name as keyof typeof handler const commandHandler = handler[commandName] - if (!commandHandler) { await notFoundHandler({}) return } - - // Check if it's a direct command or has subcommands - if (typeof commandHandler === 'function') { - // Parse arguments from top-level options - const args = parseOptions(data.options) - await commandHandler(args) - } else { - // Handle subcommands - const subcommand = data.options?.find( - (opt) => opt.type === ApplicationCommandOptionTypes.SubCommand || opt.type === ApplicationCommandOptionTypes.SubCommandGroup - ) - - if (!subcommand) { - await notFoundHandler({}) - return - } - - const subHandler = commandHandler[subcommand.name as keyof typeof commandHandler] - if (!subHandler || typeof subHandler !== 'function') { - await notFoundHandler({}) - return - } - - // Parse arguments from subcommand options - const args = parseOptions(subcommand.options) - await (subHandler as HandlerFunction)(args) - } + // Use unified handler for all command types + await handleCommands(commandHandler, data.options, notFoundHandler) } } diff --git a/ts/src/workflows/discord.ts b/ts/src/workflows/discord.ts index 59b80fe..c785f71 100644 --- a/ts/src/workflows/discord.ts +++ b/ts/src/workflows/discord.ts @@ -2,8 +2,8 @@ import { InteractionTypes } from '@discordeno/types' import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow' import type * as activities from '#/activities' import type { InteractionCreatePayload } from '#/discord' -import { createCommandHandler } from '#/discord/botevent/command_parser' -import type { SLASH_COMMANDS } from '#/discord/botevent/slash_commands' +import { CommandHandlers, createCommandHandler } from '#/discord/botevent/command_parser' +import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands' import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages' import { handleCommandPlayerLookup } from './player_messages' @@ -29,7 +29,8 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa await notFoundHandler(`Invalid command data`) return } - const commandHandler = createCommandHandler({ + const commandHandler = createCommandHandler({ + commands: SLASH_COMMANDS, notFoundHandler: async () => { await notFoundHandler(`command not found`) },