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 { Embed } from './types';
import { UnicordArgumentType, UnicordEventType } from './types/common';
import { log } from './utils';
type UnicordCommandDefinition<Options extends UnicordCommandOptions> = {
options: Options;
handler: UnicordHandler<Options>;
handler: UnicordCommandHandler<Options>;
};
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<string, UnicordCommandDefinition<any>[]>(),
[UnicordEventType.ApplicationCommands]: new Map<string, UnicordCommandDefinition<any>[]>(),
[UnicordEventType.ChatCommands]: new Map<
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) {
@@ -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}>` : ` <?${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 <const Options extends UnicordCommandOptions>(
private async validateAndResolveArgs<const Options extends UnicordCommandOptions>(
args: any[],
definitions: Options['args'],
): Promise<ArgumentTypeFromOptions<Options['args']>> => {
): Promise<ArgumentTypeFromOptions<Options['args']>> {
// 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<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
register<const Options extends UnicordCommandOptions>(
@@ -111,19 +149,19 @@ export class UnicordCommandManager {
handler: UnicordCommandHandler<Options>,
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<Options>(args, options.args);
return handler(context, resolvedArgs);
}) as UnicordCommandHandler<Options>;
this.handlers[type].set(event, { options, handler, wrappedHandler });
this.eventProcessor.register(`${type}:${event}`, wrappedHandler);
return this.self;
}
+1 -1
View File
@@ -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) {
+4
View File
@@ -12,6 +12,10 @@ export class UnicordEventProcessor extends EventEmitter {
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 {
return super.emit(event, context, args);
}
+1 -1
View File
@@ -61,7 +61,7 @@ export class UnicordGatewayManager {
this.indentify();
} else if (code === 11) {
// 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 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<UnicordConfigWithDefaults>(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: Options = { args: [] } as unknown as Options,
) {
return this.commandManager.registerChatCommand(event, handler, options);
): this {
this.commandManager.registerChatCommand(event, handler, options);
return this;
}
registerApplicationCommand<const Options extends UnicordCommandOptions>(
event: string,
handler: UnicordCommandHandler<Options>,
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;
}
}
+28 -5
View File
@@ -30,11 +30,30 @@ export enum UnicordArgumentType {
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 {
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<string, any> {
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<T extends readonly UnicordArgumentDefinition[]> = {
[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;
};
+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> = {};
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];
}
return final;
return final as T;
}
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);