refactor: update command and event handling, improve logging, and enhance argument validation, add client inheritance

This commit is contained in:
2025-11-02 01:03:30 -06:00
parent 898ceec96d
commit 5beda9328b
8 changed files with 193 additions and 74 deletions
+91 -53
View File
@@ -10,13 +10,14 @@ import { UnicordCommandContext } from './context';
import type { UnicordEventProcessor } from './eventProcessor'; import type { UnicordEventProcessor } from './eventProcessor';
import { Embed } from './types'; import { Embed } from './types';
import { UnicordArgumentType, UnicordEventType } from './types/common'; import { UnicordArgumentType, UnicordEventType } from './types/common';
import { log } from './utils';
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = { type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
options: Options; options: Options;
handler: UnicordHandler<Options>; handler: UnicordCommandHandler<Options>;
}; };
export const OptionConstructorMap = { export const ArgumentConstructorMap = {
[UnicordArgumentType.String]: String, [UnicordArgumentType.String]: String,
[UnicordArgumentType.Integer]: Number, [UnicordArgumentType.Integer]: Number,
[UnicordArgumentType.Boolean]: Boolean, [UnicordArgumentType.Boolean]: Boolean,
@@ -32,8 +33,14 @@ export const OptionConstructorMap = {
export class UnicordCommandManager { export class UnicordCommandManager {
private handlers = { private handlers = {
[UnicordEventType.ChatCommands]: new Map<string, UnicordCommandDefinition<any>[]>(), [UnicordEventType.ChatCommands]: new Map<
[UnicordEventType.ApplicationCommands]: new Map<string, UnicordCommandDefinition<any>[]>(), string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
[UnicordEventType.ApplicationCommands]: new Map<
string,
UnicordCommandDefinition<any> & { wrappedHandler: UnicordCommandHandler<any> }
>(),
}; };
constructor(private readonly self: Unicord, private readonly eventProcessor: UnicordEventProcessor) { constructor(private readonly self: Unicord, private readonly eventProcessor: UnicordEventProcessor) {
@@ -42,53 +49,13 @@ export class UnicordCommandManager {
[UnicordEventType.ApplicationCommands]: new Map(), [UnicordEventType.ApplicationCommands]: new Map(),
}; };
// Register the default help command this.registerDefaultHelpCommand();
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}>` : ` <?${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: [],
},
);
} }
private validateAndResolveArgs = async <const Options extends UnicordCommandOptions>( private async validateAndResolveArgs<const Options extends UnicordCommandOptions>(
args: any[], args: any[],
definitions: Options['args'], definitions: Options['args'],
): Promise<ArgumentTypeFromOptions<Options['args']>> => { ): Promise<ArgumentTypeFromOptions<Options['args']>> {
// Zip the args and definition together // Zip the args and definition together
const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]); const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]);
// Validate the args // Validate the args
@@ -102,7 +69,78 @@ export class UnicordCommandManager {
} }
// TODO: Fix types so we don't need to cast here // TODO: Fix types so we don't need to cast here
return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>); return Promise.resolve(validated as ArgumentTypeFromOptions<Options['args']>);
}; }
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}>` : ` <?${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 // TODO: Register application commands with the DiscordAPI
register<const Options extends UnicordCommandOptions>( register<const Options extends UnicordCommandOptions>(
@@ -111,19 +149,19 @@ export class UnicordCommandManager {
handler: UnicordCommandHandler<Options>, handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options, options: Options = { args: [] } as unknown as Options,
) { ) {
if (this.handlers[type].has(event) === false) { if (this.has(type, event)) {
this.handlers[type].set(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 wrappedHandler = (async (context: UnicordCommandContext, args: any[]) => {
const resolvedArgs = await this.validateAndResolveArgs<Options>(args, options.args); const resolvedArgs = await this.validateAndResolveArgs<Options>(args, options.args);
return handler(context, resolvedArgs); return handler(context, resolvedArgs);
}) as UnicordCommandHandler<Options>; }) as UnicordCommandHandler<Options>;
this.handlers[type].set(event, { options, handler, wrappedHandler });
this.eventProcessor.register(`${type}:${event}`, wrappedHandler); this.eventProcessor.register(`${type}:${event}`, wrappedHandler);
return this.self; return this.self;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ export class UnicordDiscordAPI {
const limitBucket = response.headers.get('X-RateLimit-Bucket'); const limitBucket = response.headers.get('X-RateLimit-Bucket');
// Log rate limit info for debugging // 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 status is 429, we are being rate limited
if (response.status === 429) { if (response.status === 429) {
+4
View File
@@ -12,6 +12,10 @@ export class UnicordEventProcessor extends EventEmitter {
return super.on(event, handler); return super.on(event, handler);
} }
unregister<const Options extends UnicordCommandOptions>(event: string, handler: UnicordHandler<Options>) {
return super.off(event, handler);
}
emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean { emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean {
return super.emit(event, context, args); return super.emit(event, context, args);
} }
+1 -1
View File
@@ -61,7 +61,7 @@ export class UnicordGatewayManager {
this.indentify(); this.indentify();
} else if (code === 11) { } else if (code === 11) {
// Heartbeat ACK // Heartbeat ACK
log('Heartbeat ACK'); // log('Heartbeat ACK');
} }
} }
}; };
+34 -7
View File
@@ -17,7 +17,7 @@ import type {
import { Intent, UnicordEventType } from './types/common'; import { Intent, UnicordEventType } from './types/common';
import type { DiscordMessage } from './types/message'; import type { DiscordMessage } from './types/message';
import { Message } from './types/message'; import { Message } from './types/message';
import { defaults } from './utils'; import { setDefaults } from './utils';
const UNICORD_CONFIG_DEFAULTS = { const UNICORD_CONFIG_DEFAULTS = {
intents: [Intent.DEFAULT], intents: [Intent.DEFAULT],
@@ -36,7 +36,7 @@ export class Unicord {
}; };
constructor(userConfig: UnicordConfig = {}) { constructor(userConfig: UnicordConfig = {}) {
this.config = defaults(userConfig, UNICORD_CONFIG_DEFAULTS) as UnicordConfigWithDefaults; this.config = setDefaults<UnicordConfigWithDefaults>(userConfig, UNICORD_CONFIG_DEFAULTS);
this.discordAPI = new UnicordDiscordAPI(this); this.discordAPI = new UnicordDiscordAPI(this);
this.eventProcessor = new UnicordEventProcessor(this); this.eventProcessor = new UnicordEventProcessor(this);
this.gatewayManager = new UnicordGatewayManager(this); this.gatewayManager = new UnicordGatewayManager(this);
@@ -70,6 +70,30 @@ export class Unicord {
return this.eventProcessor.register(`${type}:${event}`, handler); 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[]) { emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) {
this.eventProcessor.emit(`${type}:${event}`, context, args); this.eventProcessor.emit(`${type}:${event}`, context, args);
} }
@@ -79,16 +103,18 @@ export class Unicord {
event: string, event: string,
handler: UnicordCommandHandler<Options>, handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options, options: Options = { args: [] } as unknown as Options,
) { ): this {
return this.commandManager.registerChatCommand(event, handler, options); this.commandManager.registerChatCommand(event, handler, options);
return this;
} }
registerApplicationCommand<const Options extends UnicordCommandOptions>( registerApplicationCommand<const Options extends UnicordCommandOptions>(
event: string, event: string,
handler: UnicordCommandHandler<Options>, handler: UnicordCommandHandler<Options>,
options: Options = { args: [] } as unknown as Options, options: Options = { args: [] } as unknown as Options,
) { ): this {
return this.commandManager.registerApplicationCommand(event, handler, options); this.commandManager.registerApplicationCommand(event, handler, options);
return this;
} }
// TODO: should have a different signature for events that are not commands // 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) { if (token === null || token === undefined) {
throw new Error( throw new Error(
'You must provide a token to login. Either in the Unicord config or when calling `login()`', '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.config.token = token;
this.gatewayManager.connect(); this.gatewayManager.connect();
return this;
} }
} }
+28 -5
View File
@@ -30,11 +30,30 @@ export enum UnicordArgumentType {
Attachment = 11, Attachment = 11,
} }
// Old type
// export type ApplicationCommandOption = {
// type: ApplicationCommandOptionType;
// name: string;
// id: string;
// name_localizations?: Record<string, string> | null;
// description: string;
// description_localizations?: Record<string, string> | 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 { export interface UnicordArgumentDefinition {
name: string; name: string;
description: string; description: string;
type: UnicordArgumentType; type: UnicordArgumentType;
required: boolean; required: boolean;
choices?: Array<{ name: string; value: string | number }>;
} }
export interface UnicordCommandOptions { export interface UnicordCommandOptions {
@@ -56,7 +75,7 @@ export interface UnicordBaseContext extends Record<string, any> {
export interface UnicordEventContext extends UnicordBaseContext {} export interface UnicordEventContext extends UnicordBaseContext {}
export type OptionTypeMap = { export type DefinitionTypeMap = {
[UnicordArgumentType.String]: string; [UnicordArgumentType.String]: string;
[UnicordArgumentType.Integer]: number; [UnicordArgumentType.Integer]: number;
[UnicordArgumentType.Boolean]: boolean; [UnicordArgumentType.Boolean]: boolean;
@@ -71,10 +90,14 @@ export type OptionTypeMap = {
}; };
export type ArgumentTypeFromOptions<T extends readonly UnicordArgumentDefinition[]> = { export type ArgumentTypeFromOptions<T extends readonly UnicordArgumentDefinition[]> = {
[Option in T[number] as Option['name']]: Option['type'] extends keyof OptionTypeMap [Definition in T[number] as Definition['name']]: Definition['type'] extends keyof DefinitionTypeMap
? Option['required'] extends true // If required, keep as is; otherwise, make optional ? Definition['choices'] extends readonly { value: infer U }[]
? OptionTypeMap[Option['type']] ? Definition['required'] extends true // If required, keep as is; otherwise, make optional
: OptionTypeMap[Option['type']] | undefined ? U
: U | undefined
: Definition['required'] extends true // If required, keep as is; otherwise, make optional
? DefinitionTypeMap[Definition['type']]
: DefinitionTypeMap[Definition['type']] | undefined
: unknown; : unknown;
}; };
+8 -2
View File
@@ -1,4 +1,10 @@
export function defaults(original: Record<string, any>, defaults: Record<string, any>) { /**
* 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<T>(original: Record<string, any>, defaults: Record<string, any>): T {
const final: Record<string, any> = {}; const final: Record<string, any> = {};
for (const prop of Object.keys(defaults)) { for (const prop of Object.keys(defaults)) {
@@ -9,7 +15,7 @@ export function defaults(original: Record<string, any>, defaults: Record<string,
final[prop] = original[prop]; final[prop] = original[prop];
} }
return final; return final as T;
} }
export const isNil = (value: any): value is null | undefined => value === null || value === undefined; export const isNil = (value: any): value is null | undefined => value === null || value === undefined;
export const exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value); export const exists = <T>(value: T | null | undefined): value is NonNullable<T> => !isNil(value);
+26 -5
View File
@@ -73,11 +73,32 @@ const client = new Unicord({
console.log(`Logged in as ${payload.user.username}`); 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 // Allow use of "child" instances inside parent instance
// const client2 = new Unicord().chatCommand('ping', async (context, _) => { const etc = new Unicord().registerChatCommand(
// await context.reply('Pong!'); '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();