noot
Some checks failed
commit-tag / commit-tag-image (map[context:./migrations file:./migrations/Dockerfile name:migrations]) (push) Successful in 16s
commit-tag / commit-tag-image (map[context:./ts file:./ts/Dockerfile name:ts]) (push) Failing after 46s

This commit is contained in:
a 2025-06-14 18:54:24 -05:00
parent 7cc1c4ab72
commit 97d85d7fc9
No known key found for this signature in database
GPG Key ID: 2F22877AA4DFDADB
3 changed files with 260 additions and 58 deletions

View File

@ -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[] ] as const satisfies CreateApplicationCommand[]
describe('createCommandHandler', () => { describe('createCommandHandler', () => {
@ -77,6 +123,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -95,6 +147,7 @@ describe('createCommandHandler', () => {
it('should handle a simple command with optional arguments', async () => { it('should handle a simple command with optional arguments', async () => {
const simpleHandler = vi.fn() const simpleHandler = vi.fn()
const notFoundHandler = vi.fn() const notFoundHandler = vi.fn()
let a: ExtractCommands<typeof TEST_COMMANDS>
const handler = createCommandHandler<typeof TEST_COMMANDS>({ const handler = createCommandHandler<typeof TEST_COMMANDS>({
handler: { handler: {
@ -103,6 +156,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -132,6 +191,12 @@ describe('createCommandHandler', () => {
list: listHandler, list: listHandler,
create: createHandler, create: createHandler,
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -167,6 +232,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -191,6 +262,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -221,6 +298,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler, notFoundHandler,
}) })
@ -232,6 +315,55 @@ describe('createCommandHandler', () => {
expect(notFoundHandler).toHaveBeenCalledTimes(2) 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<typeof TEST_COMMANDS>({
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 () => { it('should handle commands without options', async () => {
const simpleHandler = vi.fn() const simpleHandler = vi.fn()
@ -242,6 +374,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler: vi.fn(), notFoundHandler: vi.fn(),
}) })
@ -266,6 +404,12 @@ describe('createCommandHandler', () => {
list: vi.fn(), list: vi.fn(),
create: vi.fn(), create: vi.fn(),
}, },
admin: {
user: {
ban: vi.fn(),
kick: vi.fn(),
},
},
}, },
notFoundHandler: vi.fn(), notFoundHandler: vi.fn(),
}) })
@ -313,6 +457,22 @@ describe('ExtractCommands type utility', () => {
expect(enabled).toBeDefined() 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 // 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() 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()
},
},
},
}, },
}) })

View File

@ -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 { InteractionData } from '..'
import type { SLASH_COMMANDS } from './slash_commands' import type { SLASH_COMMANDS } from './slash_commands'
@ -16,10 +16,10 @@ type OptionTypeMap = {
} }
// Helper type to get option by name // Helper type to get option by name
type GetOption<Options, Name> = Options extends readonly any[] ? (Options[number] extends infer O ? (O extends { name: Name } ? O : never) : never) : never type GetOption<Options, Name> = Options extends readonly DiscordApplicationCommandOption[] ? (Options[number] extends infer O ? (O extends { name: Name } ? O : never) : never) : never
// Extract the argument types from command options // Extract the argument types from command options
export type ExtractArgs<Options extends readonly any[]> = { export type ExtractArgs<Options extends readonly DiscordApplicationCommandOption[]> = {
[K in Options[number]['name']]: GetOption<Options, K> extends { type: infer T; required?: infer R } [K in Options[number]['name']]: GetOption<Options, K> extends { type: infer T; required?: infer R }
? T extends keyof OptionTypeMap ? T extends keyof OptionTypeMap
? R extends true ? R extends true
@ -30,55 +30,73 @@ export type ExtractArgs<Options extends readonly any[]> = {
} }
// Handler function type that accepts typed arguments // Handler function type that accepts typed arguments
type HandlerFunction<Args = {}> = (args: Args) => Promise<void> | void type HandlerFunction<Args = Record<string, never>> = (args: Args) => Promise<void> | void
// Get subcommand by name
type GetSubcommand<Options, Name> = Options extends readonly any[]
// Get subcommand or subcommand group by name
type GetSubcommandOrGroup<Options, Name> = Options extends readonly DiscordApplicationCommandOption[]
? Options[number] extends infer O ? Options[number] extends infer O
? O extends { name: Name; type: ApplicationCommandOptionTypes.SubCommand } ? O extends { name: Name; type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup }
? O ? O
: never : never
: never : never
: never : never
// Check if all options are subcommands // Check if all options are subcommands or subcommand groups
type HasOnlySubcommands<Options extends readonly any[]> = Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand } ? true : false type HasOnlySubcommands<Options extends readonly DiscordApplicationCommandOption[]> = Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup } ? true : false
// Extract subcommand names from options // Extract subcommand or subcommand group names from options
type SubcommandNames<Options extends readonly any[]> = Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand } type SubcommandNames<Options extends readonly DiscordApplicationCommandOption[]> = Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup }
? N extends string ? N extends string
? N ? N
: never : never
: never : never
// Type to extract subcommand handlers // Type to extract subcommand handlers (recursive for groups)
export type SubcommandHandlers<Options extends readonly any[]> = { export type SubcommandHandlers<Options extends readonly DiscordApplicationCommandOption[]> = {
[K in SubcommandNames<Options>]: GetSubcommand<Options, K> extends { options?: infer SubOpts } [K in SubcommandNames<Options>]: GetSubcommandOrGroup<Options, K> extends { type: infer T; options?: infer SubOpts }
? SubOpts extends readonly any[] ? T extends ApplicationCommandOptionTypes.SubCommandGroup
? SubOpts extends readonly DiscordApplicationCommandOption[]
? SubcommandHandlers<SubOpts>
: never
: T extends ApplicationCommandOptionTypes.SubCommand
? SubOpts extends readonly DiscordApplicationCommandOption[]
? HandlerFunction<ExtractArgs<SubOpts>> ? HandlerFunction<ExtractArgs<SubOpts>>
: HandlerFunction<{}> : HandlerFunction<Record<string, never>>
: HandlerFunction<{}> : never
: never
} }
// Get command by name from array // Get command by name from array
type GetCommand<Commands extends readonly any[], Name> = Commands[number] extends infer C ? (C extends { name: Name } ? C : never) : never type GetCommand<Commands extends readonly any[], Name> = Commands[number] extends infer C ? (C extends { name: Name } ? C : never) : never
// Main type to extract command handlers from slash commands // Main type to extract command handlers from slash commands
export type ExtractCommands<T extends readonly any[]> = { export type ExtractCommands<T extends readonly CreateApplicationCommand[]> = {
[Name in T[number]['name']]: GetCommand<T, Name> extends { options?: infer Options } [Name in T[number]['name']]: GetCommand<T, Name> extends { options?: infer Options }
? Options extends readonly any[] ? Options extends readonly DiscordApplicationCommandOption[]
? HasOnlySubcommands<Options> extends true ? HasOnlySubcommands<Options> extends true
? SubcommandHandlers<Options> ? SubcommandHandlers<Options>
: HandlerFunction<ExtractArgs<Options>> : HandlerFunction<ExtractArgs<Options>>
: HandlerFunction<{}> : HandlerFunction<Record<string, never>>
: HandlerFunction<{}> : HandlerFunction<Record<string, never>>
}
// Type representing the possible output of ExtractCommands
export type CommandHandler = HandlerFunction<any> | CommandHandlerMap
export type CommandHandlerMap = {
[key: string]: CommandHandler
} }
// The actual command handler type based on SLASH_COMMANDS // The actual command handler type based on SLASH_COMMANDS
export type CommandHandlers = ExtractCommands<typeof SLASH_COMMANDS> export type CommandHandlers = ExtractCommands<typeof SLASH_COMMANDS>
// Helper function to parse option values from interaction data // Helper function to parse option values from interaction data. there is sort of a hack here
function parseOptions(options?: DiscordInteractionDataOption[]): Record<string, any> { // 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<T extends DiscordInteractionDataOption[]>(options?: T): any {
if (!options) return {} if (!options) return {}
const args: Record<string, any> = {} const args: Record<string, any> = {}
@ -87,43 +105,27 @@ function parseOptions(options?: DiscordInteractionDataOption[]): Record<string,
if (option.type === ApplicationCommandOptionTypes.SubCommand || option.type === ApplicationCommandOptionTypes.SubCommandGroup) { if (option.type === ApplicationCommandOptionTypes.SubCommand || option.type === ApplicationCommandOptionTypes.SubCommandGroup) {
continue continue
} }
args[option.name] = option.value args[option.name] = option.value
} }
return args return args
} }
// Helper function to create command handlers with type safety // Unified recursive handler for commands, subcommands, and subcommand groups
export function createCommandHandler<T extends readonly CreateApplicationCommand[]>({ async function handleCommands(
handler, handler: CommandHandler,
notFoundHandler, options: DiscordInteractionDataOption[] | undefined,
}: { notFoundHandler: HandlerFunction<{path?: string}>
handler: ExtractCommands<T> ): Promise<void> {
notFoundHandler: HandlerFunction<{}> // If handler is a function, execute it with parsed options
}) { if (typeof handler === 'function') {
return async (data: InteractionData): Promise<void> => { const args = parseOptions(options)
if (!data || !data.name) { await handler(args)
await notFoundHandler({})
return return
} }
const commandName = data.name as keyof typeof handler // Handler is a map of subcommands/groups
const commandHandler = handler[commandName] const subcommand = options?.find(
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 (opt) => opt.type === ApplicationCommandOptionTypes.SubCommand || opt.type === ApplicationCommandOptionTypes.SubCommandGroup
) )
@ -132,15 +134,37 @@ export function createCommandHandler<T extends readonly CreateApplicationCommand
return return
} }
const subHandler = commandHandler[subcommand.name as keyof typeof commandHandler] const subHandler = handler[subcommand.name]
if (!subHandler || typeof subHandler !== 'function') { if (!subHandler) {
await notFoundHandler({}) await notFoundHandler({})
return return
} }
// Parse arguments from subcommand options // Recursively handle the subcommand/group
const args = parseOptions(subcommand.options) await handleCommands(subHandler, subcommand.options, notFoundHandler)
await (subHandler as HandlerFunction<any>)(args) }
}
// Helper function to create command handlers with type safety
export function createCommandHandler<T extends readonly CreateApplicationCommand[]>({
handler,
notFoundHandler,
}: {
commands: T,
handler: ExtractCommands<T>
notFoundHandler: HandlerFunction<{path?: string}>
}) {
return async (data: InteractionData): Promise<void> => {
if (!data || !data.name) {
await notFoundHandler({})
return
}
const commandName = data.name as keyof typeof handler
const commandHandler = handler[commandName]
if (!commandHandler) {
await notFoundHandler({})
return
}
// Use unified handler for all command types
await handleCommands(commandHandler, data.options, notFoundHandler)
} }
} }

View File

@ -2,8 +2,8 @@ import { InteractionTypes } from '@discordeno/types'
import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow' import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow'
import type * as activities from '#/activities' import type * as activities from '#/activities'
import type { InteractionCreatePayload } from '#/discord' import type { InteractionCreatePayload } from '#/discord'
import { createCommandHandler } from '#/discord/botevent/command_parser' import { CommandHandlers, createCommandHandler } from '#/discord/botevent/command_parser'
import type { SLASH_COMMANDS } from '#/discord/botevent/slash_commands' import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands'
import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages' import { handleCommandGuildInfo, handleCommandGuildLeaderboard, handleCommandGuildOnline } from './guild_messages'
import { handleCommandPlayerLookup } from './player_messages' import { handleCommandPlayerLookup } from './player_messages'
@ -29,7 +29,8 @@ const workflowHandleApplicationCommand = async (payload: InteractionCreatePayloa
await notFoundHandler(`Invalid command data`) await notFoundHandler(`Invalid command data`)
return return
} }
const commandHandler = createCommandHandler<typeof SLASH_COMMANDS>({ const commandHandler = createCommandHandler({
commands: SLASH_COMMANDS,
notFoundHandler: async () => { notFoundHandler: async () => {
await notFoundHandler(`command not found`) await notFoundHandler(`command not found`)
}, },