noot
This commit is contained in:
parent
24c7c2b9b1
commit
a8295b09d6
Binary file not shown.
@ -2,14 +2,24 @@
|
|||||||
"name": "backend",
|
"name": "backend",
|
||||||
"packageManager": "yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728",
|
"packageManager": "yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"barrels": "barrelsby -c barrelsby.json --delete"
|
"barrels": "barrelsby -c barrelsby.json --delete",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:typecheck": "vitest typecheck",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.13.4",
|
||||||
"@types/object-hash": "^3",
|
"@types/object-hash": "^3",
|
||||||
|
"@vitest/runner": "^3.2.3",
|
||||||
|
"@vitest/ui": "^3.2.3",
|
||||||
"barrelsby": "^2.8.1",
|
"barrelsby": "^2.8.1",
|
||||||
|
"happy-dom": "^18.0.1",
|
||||||
"knip": "^5.45.0",
|
"knip": "^5.45.0",
|
||||||
"rollup": "^4.34.8",
|
"rollup": "^4.34.8",
|
||||||
"typescript": "5.7.3"
|
"typescript": "5.7.3",
|
||||||
|
"vitest": "^3.2.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordeno/types": "^21.0.0",
|
"@discordeno/types": "^21.0.0",
|
||||||
@ -21,7 +31,6 @@
|
|||||||
"@temporalio/workflow": "^1.11.7",
|
"@temporalio/workflow": "^1.11.7",
|
||||||
"@ts-rest/core": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema",
|
"@ts-rest/core": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/core?feat-standard-schema",
|
||||||
"@ts-rest/fastify": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema",
|
"@ts-rest/fastify": "https://gitpkg.vercel.app/aidant/ts-rest/libs/ts-rest/fastify?feat-standard-schema",
|
||||||
"@types/node": "^22.13.4",
|
|
||||||
"any-date-parser": "^2.0.3",
|
"any-date-parser": "^2.0.3",
|
||||||
"arktype": "2.1.1",
|
"arktype": "2.1.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
|||||||
428
ts/src/discord/botevent/command_parser.spec.ts
Normal file
428
ts/src/discord/botevent/command_parser.spec.ts
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { ApplicationCommandOptionTypes, ApplicationCommandTypes, CreateApplicationCommand } from '@discordeno/types'
|
||||||
|
import { createCommandHandler, ExtractCommands } from './command_parser'
|
||||||
|
import type { InteractionData } from '..'
|
||||||
|
|
||||||
|
// Test command definitions
|
||||||
|
const TEST_COMMANDS = [
|
||||||
|
{
|
||||||
|
name: 'simple',
|
||||||
|
description: 'A simple command',
|
||||||
|
type: ApplicationCommandTypes.ChatInput,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
description: 'A message to echo',
|
||||||
|
type: ApplicationCommandOptionTypes.String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'count',
|
||||||
|
description: 'Number of times',
|
||||||
|
type: ApplicationCommandOptionTypes.Integer,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'complex',
|
||||||
|
description: 'A command with subcommands',
|
||||||
|
type: ApplicationCommandTypes.ChatInput,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
description: 'List items',
|
||||||
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'filter',
|
||||||
|
description: 'Filter string',
|
||||||
|
type: ApplicationCommandOptionTypes.String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'create',
|
||||||
|
description: 'Create item',
|
||||||
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
description: 'Item name',
|
||||||
|
type: ApplicationCommandOptionTypes.String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'enabled',
|
||||||
|
description: 'Is enabled',
|
||||||
|
type: ApplicationCommandOptionTypes.Boolean,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const satisfies CreateApplicationCommand[]
|
||||||
|
|
||||||
|
describe('createCommandHandler', () => {
|
||||||
|
it('should handle a simple command with required string argument', async () => {
|
||||||
|
const simpleHandler = vi.fn()
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: simpleHandler,
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'simple',
|
||||||
|
options: [
|
||||||
|
{ name: 'message', type: ApplicationCommandOptionTypes.String, value: 'Hello world' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(simpleHandler).toHaveBeenCalledWith({ message: 'Hello world' })
|
||||||
|
expect(notFoundHandler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle a simple command with optional arguments', async () => {
|
||||||
|
const simpleHandler = vi.fn()
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: simpleHandler,
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'simple',
|
||||||
|
options: [
|
||||||
|
{ name: 'message', type: ApplicationCommandOptionTypes.String, value: 'Hello' },
|
||||||
|
{ name: 'count', type: ApplicationCommandOptionTypes.Integer, value: 5 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(simpleHandler).toHaveBeenCalledWith({ message: 'Hello', count: 5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle subcommands correctly', async () => {
|
||||||
|
const listHandler = vi.fn()
|
||||||
|
const createHandler = vi.fn()
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: vi.fn(),
|
||||||
|
complex: {
|
||||||
|
list: listHandler,
|
||||||
|
create: createHandler,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'complex',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'create',
|
||||||
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
|
options: [
|
||||||
|
{ name: 'name', type: ApplicationCommandOptionTypes.String, value: 'Test Item' },
|
||||||
|
{ name: 'enabled', type: ApplicationCommandOptionTypes.Boolean, value: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(createHandler).toHaveBeenCalledWith({ name: 'Test Item', enabled: true })
|
||||||
|
expect(listHandler).not.toHaveBeenCalled()
|
||||||
|
expect(notFoundHandler).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call notFoundHandler for unknown commands', async () => {
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: vi.fn(),
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'unknown',
|
||||||
|
options: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(notFoundHandler).toHaveBeenCalledWith({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should call notFoundHandler for unknown subcommands', async () => {
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: vi.fn(),
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'complex',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: 'unknown',
|
||||||
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
|
options: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(notFoundHandler).toHaveBeenCalledWith({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle missing interaction data', async () => {
|
||||||
|
const notFoundHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: vi.fn(),
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler,
|
||||||
|
})
|
||||||
|
|
||||||
|
await handler(null as any)
|
||||||
|
expect(notFoundHandler).toHaveBeenCalledWith({})
|
||||||
|
|
||||||
|
await handler({} as any)
|
||||||
|
expect(notFoundHandler).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle commands without options', async () => {
|
||||||
|
const simpleHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: simpleHandler,
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'simple',
|
||||||
|
// No options provided
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(simpleHandler).toHaveBeenCalledWith({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore subcommand options when parsing top-level args', async () => {
|
||||||
|
const simpleHandler = vi.fn()
|
||||||
|
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
handler: {
|
||||||
|
simple: simpleHandler,
|
||||||
|
complex: {
|
||||||
|
list: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
notFoundHandler: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const interactionData: InteractionData = {
|
||||||
|
name: 'simple',
|
||||||
|
options: [
|
||||||
|
{ name: 'message', type: ApplicationCommandOptionTypes.String, value: 'Hello' },
|
||||||
|
// This should be ignored in parseOptions
|
||||||
|
{ name: 'subcommand', type: ApplicationCommandOptionTypes.SubCommand, options: [] },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler(interactionData)
|
||||||
|
|
||||||
|
expect(simpleHandler).toHaveBeenCalledWith({ message: 'Hello' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('ExtractCommands type utility', () => {
|
||||||
|
it('should correctly extract command types', () => {
|
||||||
|
// Type tests - these compile-time checks ensure the types work correctly
|
||||||
|
type TestHandlers = ExtractCommands<typeof TEST_COMMANDS>
|
||||||
|
|
||||||
|
// Test that the type structure is correct
|
||||||
|
const handlers: TestHandlers = {
|
||||||
|
simple: async (args) => {
|
||||||
|
// TypeScript should know that args has message as required string and count as optional number
|
||||||
|
const message: string = args.message
|
||||||
|
const count: number | undefined = args.count
|
||||||
|
expect(message).toBeDefined()
|
||||||
|
expect(count).toBeDefined()
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
list: async (args) => {
|
||||||
|
// TypeScript should know that args has filter as optional string
|
||||||
|
const filter: string | undefined = args.filter
|
||||||
|
expect(filter).toBeDefined()
|
||||||
|
},
|
||||||
|
create: async (args) => {
|
||||||
|
// TypeScript should know that args has name as required string and enabled as optional boolean
|
||||||
|
const name: string = args.name
|
||||||
|
const enabled: boolean | undefined = args.enabled
|
||||||
|
expect(name).toBeDefined()
|
||||||
|
expect(enabled).toBeDefined()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just to satisfy TypeScript - the real test is that the above compiles
|
||||||
|
expect(handlers).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should infer types correctly without manual typing', () => {
|
||||||
|
// This test verifies that args are inferred correctly without manual type annotations
|
||||||
|
const handler = createCommandHandler<typeof TEST_COMMANDS>({
|
||||||
|
notFoundHandler: async () => {},
|
||||||
|
handler: {
|
||||||
|
simple: async (args) => {
|
||||||
|
// args should be automatically typed
|
||||||
|
// This would error if args.message wasn't a string
|
||||||
|
const upper = args.message.toUpperCase()
|
||||||
|
// This would error if args.count wasn't number | undefined
|
||||||
|
const doubled = args.count ? args.count * 2 : 0
|
||||||
|
expect(upper).toBeDefined()
|
||||||
|
expect(doubled).toBeDefined()
|
||||||
|
},
|
||||||
|
complex: {
|
||||||
|
list: async (args) => {
|
||||||
|
// This would error if args.filter wasn't string | undefined
|
||||||
|
const filtered = args.filter?.toLowerCase()
|
||||||
|
expect(filtered !== undefined).toBeDefined()
|
||||||
|
},
|
||||||
|
create: async (args) => {
|
||||||
|
// This would error if types weren't correct
|
||||||
|
const nameLength = args.name.length
|
||||||
|
const isEnabled = args.enabled === true
|
||||||
|
expect(nameLength).toBeDefined()
|
||||||
|
expect(isEnabled).toBeDefined()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(handler).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle commands with no options', () => {
|
||||||
|
const EMPTY_COMMANDS = [
|
||||||
|
{
|
||||||
|
name: 'ping',
|
||||||
|
description: 'Ping command',
|
||||||
|
type: ApplicationCommandTypes.ChatInput,
|
||||||
|
},
|
||||||
|
] as const satisfies CreateApplicationCommand[]
|
||||||
|
|
||||||
|
type EmptyHandlers = ExtractCommands<typeof EMPTY_COMMANDS>
|
||||||
|
|
||||||
|
const handlers: EmptyHandlers = {
|
||||||
|
ping: async (args) => {
|
||||||
|
// args should be an empty object
|
||||||
|
expect(args).toEqual({})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(handlers).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle different option types', () => {
|
||||||
|
const TYPE_TEST_COMMANDS = [
|
||||||
|
{
|
||||||
|
name: 'types',
|
||||||
|
description: 'Test different types',
|
||||||
|
type: ApplicationCommandTypes.ChatInput,
|
||||||
|
options: [
|
||||||
|
{ name: 'str', description: 'String param', type: ApplicationCommandOptionTypes.String, required: true },
|
||||||
|
{ name: 'int', description: 'Integer param', type: ApplicationCommandOptionTypes.Integer, required: true },
|
||||||
|
{ name: 'bool', description: 'Boolean param', type: ApplicationCommandOptionTypes.Boolean, required: true },
|
||||||
|
{ name: 'user', description: 'User param', type: ApplicationCommandOptionTypes.User, required: true },
|
||||||
|
{ name: 'channel', description: 'Channel param', type: ApplicationCommandOptionTypes.Channel, required: true },
|
||||||
|
{ name: 'role', description: 'Role param', type: ApplicationCommandOptionTypes.Role, required: true },
|
||||||
|
{ name: 'num', description: 'Number param', type: ApplicationCommandOptionTypes.Number, required: true },
|
||||||
|
{ name: 'mention', description: 'Mentionable param', type: ApplicationCommandOptionTypes.Mentionable, required: true },
|
||||||
|
{ name: 'attach', description: 'Attachment param', type: ApplicationCommandOptionTypes.Attachment, required: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const satisfies CreateApplicationCommand[]
|
||||||
|
|
||||||
|
type TypeHandlers = ExtractCommands<typeof TYPE_TEST_COMMANDS>
|
||||||
|
|
||||||
|
const handlers: TypeHandlers = {
|
||||||
|
types: async (args) => {
|
||||||
|
// Test that all types are correctly mapped
|
||||||
|
const str: string = args.str
|
||||||
|
const int: number = args.int
|
||||||
|
const bool: boolean = args.bool
|
||||||
|
const user: string = args.user
|
||||||
|
const channel: string = args.channel
|
||||||
|
const role: string = args.role
|
||||||
|
const num: number = args.num
|
||||||
|
const mention: string = args.mention
|
||||||
|
const attach: string = args.attach
|
||||||
|
|
||||||
|
expect(str).toBeDefined()
|
||||||
|
expect(int).toBeDefined()
|
||||||
|
expect(bool).toBeDefined()
|
||||||
|
expect(user).toBeDefined()
|
||||||
|
expect(channel).toBeDefined()
|
||||||
|
expect(role).toBeDefined()
|
||||||
|
expect(num).toBeDefined()
|
||||||
|
expect(mention).toBeDefined()
|
||||||
|
expect(attach).toBeDefined()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(handlers).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -15,15 +15,18 @@ type OptionTypeMap = {
|
|||||||
[ApplicationCommandOptionTypes.Attachment]: string; // attachment ID
|
[ApplicationCommandOptionTypes.Attachment]: string; // attachment ID
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
// Extract the argument types from command options
|
// Extract the argument types from command options
|
||||||
type ExtractArgs<Options extends readonly any[]> = {
|
export type ExtractArgs<Options extends readonly any[]> = {
|
||||||
[K in Options[number] as K extends { name: infer N; type: infer T }
|
[K in Options[number]['name']]: GetOption<Options, K> extends { type: infer T; required?: infer R }
|
||||||
? T extends keyof OptionTypeMap
|
|
||||||
? N extends string
|
|
||||||
? N
|
|
||||||
: never
|
|
||||||
: never
|
|
||||||
: never]: Options[number] extends { name: K; type: infer T; required?: infer R }
|
|
||||||
? T extends keyof OptionTypeMap
|
? T extends keyof OptionTypeMap
|
||||||
? R extends true
|
? R extends true
|
||||||
? OptionTypeMap[T]
|
? OptionTypeMap[T]
|
||||||
@ -35,31 +38,54 @@ 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 = {}> = (args: Args) => Promise<void> | void;
|
||||||
|
|
||||||
// Helper types to extract command structure with proper argument types
|
// Get subcommand by name
|
||||||
type ExtractSubcommands<T> = T extends { options: infer O }
|
type GetSubcommand<Options, Name> = Options extends readonly any[]
|
||||||
? O extends readonly any[]
|
? Options[number] extends infer O
|
||||||
? {
|
? O extends { name: Name; type: ApplicationCommandOptionTypes.SubCommand }
|
||||||
[K in O[number] as K extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand | ApplicationCommandOptionTypes.SubCommandGroup }
|
? O
|
||||||
? N extends string
|
|
||||||
? N
|
|
||||||
: never
|
: never
|
||||||
: never]: O[number] extends { name: K; options?: infer SubO }
|
|
||||||
? SubO extends readonly any[]
|
|
||||||
? HandlerFunction<ExtractArgs<SubO>>
|
|
||||||
: HandlerFunction<{}>
|
|
||||||
: HandlerFunction<{}>
|
|
||||||
}
|
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
type ExtractCommands<T extends readonly CreateApplicationCommand[]> = {
|
// Check if all options are subcommands
|
||||||
[K in T[number] as K['name']]: ExtractSubcommands<K> extends never
|
type HasOnlySubcommands<Options extends readonly any[]> =
|
||||||
? T[number] extends { name: K; options?: infer O }
|
Options[number] extends { type: ApplicationCommandOptionTypes.SubCommand }
|
||||||
? O extends readonly any[]
|
? true
|
||||||
? HandlerFunction<ExtractArgs<O>>
|
: false;
|
||||||
|
|
||||||
|
// Extract subcommand names from options
|
||||||
|
type SubcommandNames<Options extends readonly any[]> =
|
||||||
|
Options[number] extends { name: infer N; type: ApplicationCommandOptionTypes.SubCommand }
|
||||||
|
? N extends string
|
||||||
|
? N
|
||||||
|
: never
|
||||||
|
: never;
|
||||||
|
|
||||||
|
// Type to extract subcommand handlers
|
||||||
|
export type SubcommandHandlers<Options extends readonly any[]> = {
|
||||||
|
[K in SubcommandNames<Options>]: GetSubcommand<Options, K> extends { options?: infer SubOpts }
|
||||||
|
? SubOpts extends readonly any[]
|
||||||
|
? HandlerFunction<ExtractArgs<SubOpts>>
|
||||||
|
: HandlerFunction<{}>
|
||||||
|
: HandlerFunction<{}>
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Main type to extract command handlers from slash commands
|
||||||
|
export type ExtractCommands<T extends readonly any[]> = {
|
||||||
|
[Name in T[number]['name']]: GetCommand<T, Name> extends { options?: infer Options }
|
||||||
|
? Options extends readonly any[]
|
||||||
|
? HasOnlySubcommands<Options> extends true
|
||||||
|
? SubcommandHandlers<Options>
|
||||||
|
: HandlerFunction<ExtractArgs<Options>>
|
||||||
: HandlerFunction<{}>
|
: HandlerFunction<{}>
|
||||||
: HandlerFunction<{}>
|
: HandlerFunction<{}>
|
||||||
: ExtractSubcommands<K>
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The actual command handler type based on SLASH_COMMANDS
|
// The actual command handler type based on SLASH_COMMANDS
|
||||||
@ -128,62 +154,8 @@ export function createCommandHandler<T extends readonly CreateApplicationCommand
|
|||||||
|
|
||||||
// Parse arguments from subcommand options
|
// Parse arguments from subcommand options
|
||||||
const args = parseOptions(subcommand.options);
|
const args = parseOptions(subcommand.options);
|
||||||
await subHandler(args);
|
await (subHandler as HandlerFunction<any>)(args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to validate that all commands are implemented
|
|
||||||
export function validateCommandImplementation(handlers: CommandHandlers): void {
|
|
||||||
for (const command of SLASH_COMMANDS) {
|
|
||||||
const commandName = command.name;
|
|
||||||
if (!(commandName in handlers)) {
|
|
||||||
throw new Error(`Command "${commandName}" is not implemented`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check subcommands if they exist
|
|
||||||
if (command.options) {
|
|
||||||
const subcommands = command.options.filter(
|
|
||||||
opt => opt.type === ApplicationCommandOptionTypes.SubCommand
|
|
||||||
);
|
|
||||||
|
|
||||||
if (subcommands.length > 0) {
|
|
||||||
const handlerObj = handlers[commandName as keyof CommandHandlers];
|
|
||||||
if (typeof handlerObj === 'function') {
|
|
||||||
throw new Error(`Command "${commandName}" has subcommands but is implemented as a function`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const subcommand of subcommands) {
|
|
||||||
if (!(subcommand.name in handlerObj)) {
|
|
||||||
throw new Error(`Subcommand "${commandName}.${subcommand.name}" is not implemented`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create command handlers with type safety
|
|
||||||
export function createCommandHandlers<T extends CommandHandlers>(handlers: T): T {
|
|
||||||
return handlers;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example usage showing the type-safe implementation
|
|
||||||
export const exampleHandlers = createCommandHandlers({
|
|
||||||
guild: {
|
|
||||||
leaderboard: async (args: {}) => {
|
|
||||||
console.log("Guild leaderboard command");
|
|
||||||
},
|
|
||||||
info: async (args: {}) => {
|
|
||||||
console.log("Guild info command");
|
|
||||||
},
|
|
||||||
online: async (args: {}) => {
|
|
||||||
console.log("Guild online command");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
admin: {
|
|
||||||
set_wynn_guild: async (args: {}) => {
|
|
||||||
console.log("Admin set_wynn_guild command");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|||||||
@ -37,5 +37,24 @@ export const SLASH_COMMANDS = [
|
|||||||
type: ApplicationCommandOptionTypes.SubCommand,
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},{
|
||||||
|
name: "player",
|
||||||
|
description: "player commands",
|
||||||
|
type: ApplicationCommandTypes.ChatInput,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "lookup",
|
||||||
|
description: "view player information",
|
||||||
|
type: ApplicationCommandOptionTypes.SubCommand,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
name: "player",
|
||||||
|
description: "player name",
|
||||||
|
type: ApplicationCommandOptionTypes.String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
] as const satisfies CreateApplicationCommand[]
|
] as const satisfies CreateApplicationCommand[]
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { proxyActivities, startChild, workflowInfo } from '@temporalio/workflow'
|
|||||||
import type * as activities from '#/activities';
|
import type * as activities from '#/activities';
|
||||||
import { InteractionTypes } from '@discordeno/types';
|
import { InteractionTypes } from '@discordeno/types';
|
||||||
import { handleCommandGuildInfo, handleCommandGuildOnline, handleCommandGuildLeaderboard } from './guild_messages';
|
import { handleCommandGuildInfo, handleCommandGuildOnline, handleCommandGuildLeaderboard } from './guild_messages';
|
||||||
|
import { handleCommandPlayerLookup } from './player_messages';
|
||||||
import { createCommandHandler } from '#/discord/botevent/command_parser';
|
import { createCommandHandler } from '#/discord/botevent/command_parser';
|
||||||
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands';
|
import { SLASH_COMMANDS } from '#/discord/botevent/slash_commands';
|
||||||
import { InteractionCreatePayload} from '#/discord';
|
import { InteractionCreatePayload} from '#/discord';
|
||||||
@ -34,9 +35,19 @@ const workflowHandleApplicationCommand = async (
|
|||||||
notFoundHandler: async () => {
|
notFoundHandler: async () => {
|
||||||
await notFoundHandler(`command not found`);
|
await notFoundHandler(`command not found`);
|
||||||
},
|
},
|
||||||
handler:{
|
handler: {
|
||||||
|
player: {
|
||||||
|
lookup: async (args) => {
|
||||||
|
const { workflowId } = workflowInfo();
|
||||||
|
const handle = await startChild(handleCommandPlayerLookup, {
|
||||||
|
args: [{ ref, args }],
|
||||||
|
workflowId: `${workflowId}-player-lookup`,
|
||||||
|
});
|
||||||
|
await handle.result();
|
||||||
|
},
|
||||||
|
},
|
||||||
guild: {
|
guild: {
|
||||||
info: async (args: {}) => {
|
info: async (args) => {
|
||||||
const { workflowId } = workflowInfo();
|
const { workflowId } = workflowInfo();
|
||||||
const handle = await startChild(handleCommandGuildInfo, {
|
const handle = await startChild(handleCommandGuildInfo, {
|
||||||
args: [{ ref }],
|
args: [{ ref }],
|
||||||
@ -44,7 +55,7 @@ const workflowHandleApplicationCommand = async (
|
|||||||
});
|
});
|
||||||
await handle.result();
|
await handle.result();
|
||||||
},
|
},
|
||||||
online: async (args: {}) => {
|
online: async (args) => {
|
||||||
const { workflowId } = workflowInfo();
|
const { workflowId } = workflowInfo();
|
||||||
const handle = await startChild(handleCommandGuildOnline, {
|
const handle = await startChild(handleCommandGuildOnline, {
|
||||||
args: [{ ref }],
|
args: [{ ref }],
|
||||||
@ -52,7 +63,7 @@ const workflowHandleApplicationCommand = async (
|
|||||||
});
|
});
|
||||||
await handle.result();
|
await handle.result();
|
||||||
},
|
},
|
||||||
leaderboard: async (args: {}) => {
|
leaderboard: async (args) => {
|
||||||
const { workflowId } = workflowInfo();
|
const { workflowId } = workflowInfo();
|
||||||
const handle = await startChild(handleCommandGuildLeaderboard, {
|
const handle = await startChild(handleCommandGuildLeaderboard, {
|
||||||
args: [{ ref }],
|
args: [{ ref }],
|
||||||
@ -62,7 +73,7 @@ const workflowHandleApplicationCommand = async (
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
set_wynn_guild: async (args: {}) => {
|
set_wynn_guild: async (args) => {
|
||||||
await reply_to_interaction({
|
await reply_to_interaction({
|
||||||
ref,
|
ref,
|
||||||
type: 4,
|
type: 4,
|
||||||
|
|||||||
@ -6,4 +6,5 @@ export * from "./discord";
|
|||||||
export * from "./guild_messages";
|
export * from "./guild_messages";
|
||||||
export * from "./guilds";
|
export * from "./guilds";
|
||||||
export * from "./items";
|
export * from "./items";
|
||||||
|
export * from "./player_messages";
|
||||||
export * from "./players";
|
export * from "./players";
|
||||||
|
|||||||
43
ts/src/workflows/player_messages.ts
Normal file
43
ts/src/workflows/player_messages.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { proxyActivities } from "@temporalio/workflow";
|
||||||
|
import type * as activities from "#/activities";
|
||||||
|
import { InteractionRef } from "#/discord";
|
||||||
|
|
||||||
|
const {
|
||||||
|
reply_to_interaction
|
||||||
|
} = proxyActivities<typeof activities>({
|
||||||
|
startToCloseTimeout: '30 seconds',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CommandPayload {
|
||||||
|
ref: InteractionRef;
|
||||||
|
args: {
|
||||||
|
player: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCommandPlayerLookup(payload: CommandPayload): Promise<void> {
|
||||||
|
const { ref, args } = payload;
|
||||||
|
const playerName = args.player;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For now, we'll send a simple response
|
||||||
|
// In the future, we can fetch player stats from Wynncraft API
|
||||||
|
await reply_to_interaction({
|
||||||
|
ref,
|
||||||
|
type: 4,
|
||||||
|
options: {
|
||||||
|
content: `Looking up player: **${playerName}**\n\n*Player lookup functionality coming soon!*`,
|
||||||
|
isPrivate: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await reply_to_interaction({
|
||||||
|
ref,
|
||||||
|
type: 4,
|
||||||
|
options: {
|
||||||
|
content: `Error looking up player: ${playerName}`,
|
||||||
|
isPrivate: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
ts/vitest.config.ts
Normal file
18
ts/vitest.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
include: ['**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
typecheck: {
|
||||||
|
enabled: true,
|
||||||
|
tsconfig: './tsconfig.json',
|
||||||
|
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'#': new URL('./src', import.meta.url).pathname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1065
ts/yarn.lock
1065
ts/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user