From 5beda9328b075fed2586bafebab0ee7de1d4ff76 Mon Sep 17 00:00:00 2001 From: Jaron Date: Sun, 2 Nov 2025 01:03:30 -0600 Subject: [PATCH] refactor: update command and event handling, improve logging, and enhance argument validation, add client inheritance --- rewrite/commandManager.ts | 144 +++++++++++++++++++----------- rewrite/discordAPI.ts | 2 +- rewrite/eventProcessor.ts | 4 + rewrite/gatewayManager.ts | 2 +- rewrite/index.ts | 41 +++++++-- rewrite/types/common.ts | 33 +++++-- rewrite/utils.ts | 10 ++- testing/inferface.rewrite.test.ts | 31 +++++-- 8 files changed, 193 insertions(+), 74 deletions(-) diff --git a/rewrite/commandManager.ts b/rewrite/commandManager.ts index 4ad4f7e..a74447d 100644 --- a/rewrite/commandManager.ts +++ b/rewrite/commandManager.ts @@ -10,13 +10,14 @@ import { UnicordCommandContext } from './context'; import type { UnicordEventProcessor } from './eventProcessor'; import { Embed } from './types'; import { UnicordArgumentType, UnicordEventType } from './types/common'; +import { log } from './utils'; type UnicordCommandDefinition = { options: Options; - handler: UnicordHandler; + handler: UnicordCommandHandler; }; -export const OptionConstructorMap = { +export const ArgumentConstructorMap = { [UnicordArgumentType.String]: String, [UnicordArgumentType.Integer]: Number, [UnicordArgumentType.Boolean]: Boolean, @@ -32,8 +33,14 @@ export const OptionConstructorMap = { export class UnicordCommandManager { private handlers = { - [UnicordEventType.ChatCommands]: new Map[]>(), - [UnicordEventType.ApplicationCommands]: new Map[]>(), + [UnicordEventType.ChatCommands]: new Map< + string, + UnicordCommandDefinition & { wrappedHandler: UnicordCommandHandler } + >(), + [UnicordEventType.ApplicationCommands]: new Map< + string, + UnicordCommandDefinition & { wrappedHandler: UnicordCommandHandler } + >(), }; constructor(private readonly self: Unicord, private readonly eventProcessor: UnicordEventProcessor) { @@ -42,53 +49,13 @@ export class UnicordCommandManager { [UnicordEventType.ApplicationCommands]: new Map(), }; - // Register the default help command - this.registerChatCommand( - 'help', - async (context) => { - // Just list chat commands for now - const reply = new Embed().setTitle('Available Commands'); - - for (const [command, defs] of this.handlers[UnicordEventType.ChatCommands]) { - for (const def of defs) { - let fieldValue = def.options.description ?? 'No description'; - fieldValue += '\n'; - - if (def.options.args.length > 0) { - fieldValue += `\n\`${command}`; - for (const arg of def.options.args) { - fieldValue += arg.required ? ` <${arg.name}>` : ` `; - } - fieldValue += '`\n'; - for (const arg of def.options.args) { - fieldValue += `- \`${arg.name}\` (${UnicordArgumentType[arg.type]}${ - arg.required ? ', required' : '' - }): ${arg.description ?? 'No description'}\n`; - } - } - - fieldValue += '\n'; - - reply.fields.push({ - name: command, - value: fieldValue, - }); - } - } - - await context.reply(reply.toMessage()); - }, - { - description: 'Lists all available commands', - args: [], - }, - ); + this.registerDefaultHelpCommand(); } - private validateAndResolveArgs = async ( + private async validateAndResolveArgs( args: any[], definitions: Options['args'], - ): Promise> => { + ): Promise> { // Zip the args and definition together const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]); // Validate the args @@ -102,7 +69,78 @@ export class UnicordCommandManager { } // TODO: Fix types so we don't need to cast here return Promise.resolve(validated as ArgumentTypeFromOptions); - }; + } + + registerDefaultHelpCommand() { + this.registerChatCommand( + 'help', + async (context) => { + // Just list chat commands for now + const reply = new Embed().setTitle('Available Commands'); + + for (const [command, def] of this.handlers[UnicordEventType.ChatCommands]) { + let fieldValue = def.options.description ?? 'No description'; + fieldValue += '\n'; + + if (def.options.args.length > 0) { + fieldValue += `\n\`${command}`; + for (const arg of def.options.args) { + fieldValue += arg.required ? ` <${arg.name}>` : ` `; + } + fieldValue += '`\n'; + for (const arg of def.options.args) { + fieldValue += `- \`${arg.name}\` (${UnicordArgumentType[arg.type]}${ + arg.required ? ', required' : '' + }): ${arg.description ?? 'No description'}\n`; + } + } + + fieldValue += '\n'; + + reply.fields.push({ + name: command, + value: fieldValue, + }); + } + + await context.reply(reply.toMessage()); + }, + { + description: 'Lists all available commands', + args: [], + }, + ); + } + + all() { + return this.handlers; + } + + has(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) { + return this.handlers[type].has(event); + } + + merge(other: UnicordCommandManager) { + const handlers = other.all(); + + for (const type in handlers) { + const typeKey = type as UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands; + for (const [event, definition] of handlers[typeKey]) { + this.register(typeKey, event, definition.handler, definition.options); + } + } + + this.registerDefaultHelpCommand(); + } + + unregister(type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string) { + const handler = this.handlers[type].get(event); + if (handler) { + this.eventProcessor.unregister(`${type}:${event}`, handler.wrappedHandler); + this.handlers[type].delete(event); + } + return this.self; + } // TODO: Register application commands with the DiscordAPI register( @@ -111,19 +149,19 @@ export class UnicordCommandManager { handler: UnicordCommandHandler, options: Options = { args: [] } as unknown as Options, ) { - if (this.handlers[type].has(event) === false) { - this.handlers[type].set(event, []); + if (this.has(type, event)) { + this.unregister(type, event); } - this.handlers[type].get(event)!.push({ options, handler }); - - // Generate a closure to wrap the handler const wrappedHandler = (async (context: UnicordCommandContext, args: any[]) => { const resolvedArgs = await this.validateAndResolveArgs(args, options.args); return handler(context, resolvedArgs); }) as UnicordCommandHandler; + this.handlers[type].set(event, { options, handler, wrappedHandler }); + this.eventProcessor.register(`${type}:${event}`, wrappedHandler); + return this.self; } diff --git a/rewrite/discordAPI.ts b/rewrite/discordAPI.ts index 542681e..b879903 100644 --- a/rewrite/discordAPI.ts +++ b/rewrite/discordAPI.ts @@ -41,7 +41,7 @@ export class UnicordDiscordAPI { const limitBucket = response.headers.get('X-RateLimit-Bucket'); // Log rate limit info for debugging - log(`Rate Limit: ${remaining}/${limit} - Reset: ${reset} (${resetSeconds}s) - Bucket: ${limitBucket}`); + log(`Rate Limit: ${remaining}/${limit} - Reset: ${resetSeconds}s - Bucket: ${limitBucket}`); // If status is 429, we are being rate limited if (response.status === 429) { diff --git a/rewrite/eventProcessor.ts b/rewrite/eventProcessor.ts index 4d3bcd1..8d9051b 100644 --- a/rewrite/eventProcessor.ts +++ b/rewrite/eventProcessor.ts @@ -12,6 +12,10 @@ export class UnicordEventProcessor extends EventEmitter { return super.on(event, handler); } + unregister(event: string, handler: UnicordHandler) { + return super.off(event, handler); + } + emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean { return super.emit(event, context, args); } diff --git a/rewrite/gatewayManager.ts b/rewrite/gatewayManager.ts index f1dd486..44dd3bc 100644 --- a/rewrite/gatewayManager.ts +++ b/rewrite/gatewayManager.ts @@ -61,7 +61,7 @@ export class UnicordGatewayManager { this.indentify(); } else if (code === 11) { // Heartbeat ACK - log('Heartbeat ACK'); + // log('Heartbeat ACK'); } } }; diff --git a/rewrite/index.ts b/rewrite/index.ts index 23c9f3f..e414501 100644 --- a/rewrite/index.ts +++ b/rewrite/index.ts @@ -17,7 +17,7 @@ import type { import { Intent, UnicordEventType } from './types/common'; import type { DiscordMessage } from './types/message'; import { Message } from './types/message'; -import { defaults } from './utils'; +import { setDefaults } from './utils'; const UNICORD_CONFIG_DEFAULTS = { intents: [Intent.DEFAULT], @@ -36,7 +36,7 @@ export class Unicord { }; constructor(userConfig: UnicordConfig = {}) { - this.config = defaults(userConfig, UNICORD_CONFIG_DEFAULTS) as UnicordConfigWithDefaults; + this.config = setDefaults(userConfig, UNICORD_CONFIG_DEFAULTS); this.discordAPI = new UnicordDiscordAPI(this); this.eventProcessor = new UnicordEventProcessor(this); this.gatewayManager = new UnicordGatewayManager(this); @@ -70,6 +70,30 @@ export class Unicord { return this.eventProcessor.register(`${type}:${event}`, handler); } + use(client: Unicord): this { + // const handlers = client.commandManager.all(); + + // for (const type in handlers) { + // const typeKey = type as UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands; + // for (const [event, definition] of handlers[typeKey]) { + // this.commandManager.register( + // typeKey, + // event, + // definition.handler.bind(this.commandManager), + // definition.options, + // ); + // } + // } + + // this.commandManager.registerDefaultHelpCommand(); + + this.commandManager.merge(client.commandManager); + + // TODO: inherit event listeners too + + return this; + } + emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) { this.eventProcessor.emit(`${type}:${event}`, context, args); } @@ -79,16 +103,18 @@ export class Unicord { event: string, handler: UnicordCommandHandler, options: Options = { args: [] } as unknown as Options, - ) { - return this.commandManager.registerChatCommand(event, handler, options); + ): this { + this.commandManager.registerChatCommand(event, handler, options); + return this; } registerApplicationCommand( event: string, handler: UnicordCommandHandler, options: Options = { args: [] } as unknown as Options, - ) { - return this.commandManager.registerApplicationCommand(event, handler, options); + ): this { + this.commandManager.registerApplicationCommand(event, handler, options); + return this; } // TODO: should have a different signature for events that are not commands @@ -107,7 +133,7 @@ export class Unicord { ); }; - login(token: string | undefined = this.config.token) { + login(token: string | undefined = this.config.token): this { if (token === null || token === undefined) { throw new Error( 'You must provide a token to login. Either in the Unicord config or when calling `login()`', @@ -117,6 +143,7 @@ export class Unicord { this.config.token = token; this.gatewayManager.connect(); + return this; } } diff --git a/rewrite/types/common.ts b/rewrite/types/common.ts index 06e9e3b..4c0a9ab 100644 --- a/rewrite/types/common.ts +++ b/rewrite/types/common.ts @@ -30,11 +30,30 @@ export enum UnicordArgumentType { Attachment = 11, } +// Old type +// export type ApplicationCommandOption = { +// type: ApplicationCommandOptionType; +// name: string; +// id: string; +// name_localizations?: Record | null; +// description: string; +// description_localizations?: Record | null; +// required?: boolean; +// choices?: Array<{ name: string; value: string | number }>; +// options?: ApplicationCommandOption[]; // Recursive structure for subcommands +// channel_types?: ChannelType[]; +// min_value?: number; +// max_value?: number; +// min_length?: number; +// max_length?: number; +// autocomplete?: boolean; +// }; export interface UnicordArgumentDefinition { name: string; description: string; type: UnicordArgumentType; required: boolean; + choices?: Array<{ name: string; value: string | number }>; } export interface UnicordCommandOptions { @@ -56,7 +75,7 @@ export interface UnicordBaseContext extends Record { export interface UnicordEventContext extends UnicordBaseContext {} -export type OptionTypeMap = { +export type DefinitionTypeMap = { [UnicordArgumentType.String]: string; [UnicordArgumentType.Integer]: number; [UnicordArgumentType.Boolean]: boolean; @@ -71,10 +90,14 @@ export type OptionTypeMap = { }; export type ArgumentTypeFromOptions = { - [Option in T[number] as Option['name']]: Option['type'] extends keyof OptionTypeMap - ? Option['required'] extends true // If required, keep as is; otherwise, make optional - ? OptionTypeMap[Option['type']] - : OptionTypeMap[Option['type']] | undefined + [Definition in T[number] as Definition['name']]: Definition['type'] extends keyof DefinitionTypeMap + ? Definition['choices'] extends readonly { value: infer U }[] + ? Definition['required'] extends true // If required, keep as is; otherwise, make optional + ? U + : U | undefined + : Definition['required'] extends true // If required, keep as is; otherwise, make optional + ? DefinitionTypeMap[Definition['type']] + : DefinitionTypeMap[Definition['type']] | undefined : unknown; }; diff --git a/rewrite/utils.ts b/rewrite/utils.ts index fb7a9fe..3457de3 100644 --- a/rewrite/utils.ts +++ b/rewrite/utils.ts @@ -1,4 +1,10 @@ -export function defaults(original: Record, defaults: Record) { +/** + * Sets top level properties when undefined on the original object — cretates a new object, does not modify the original + * @param original original object + * @param defaults defailts to set on the object + * @returns a new object with top level defaults set + */ +export function setDefaults(original: Record, defaults: Record): T { const final: Record = {}; for (const prop of Object.keys(defaults)) { @@ -9,7 +15,7 @@ export function defaults(original: Record, defaults: Record value === null || value === undefined; export const exists = (value: T | null | undefined): value is NonNullable => !isNil(value); diff --git a/testing/inferface.rewrite.test.ts b/testing/inferface.rewrite.test.ts index 6899abb..831d524 100644 --- a/testing/inferface.rewrite.test.ts +++ b/testing/inferface.rewrite.test.ts @@ -73,11 +73,32 @@ const client = new Unicord({ console.log(`Logged in as ${payload.user.username}`); }); -client.login(DISCORD_CLIENT_TOKEN); +// client.login(DISCORD_CLIENT_TOKEN); // Allow use of "child" instances inside parent instance -// const client2 = new Unicord().chatCommand('ping', async (context, _) => { -// await context.reply('Pong!'); -// }); +const etc = new Unicord().registerChatCommand( + 'zing', + async (context, args) => { + await context.reply(`Zing! You got served!` + args.fav_amimal ? ` One ${args.fav_amimal} please!` : ''); + }, + { + args: [ + { + name: 'fav_amimal', + description: 'Your favorite animal', + type: UnicordArgumentType.String, + required: false, + choices: [ + { name: 'Dog', value: 'dog' }, + { name: 'Cat', value: 'cat' }, + { name: 'Hamster', value: 'hamster' }, + { name: 'Dolphin', value: 'dolphin' }, + ], + }, + ], + }, +); -// new Unicord().use(client2).login(DISCORD_CLIENT_TOKEN); +client.use(etc); + +client.login(); \ No newline at end of file