From ce45eacb3b0906efb3dc3d4da0f79b8fd6cf681d Mon Sep 17 00:00:00 2001 From: Jaron Date: Sun, 17 May 2026 22:27:25 -0500 Subject: [PATCH] refactor: decouple system events from command dispatch and add explicit system event handlers and improve gateway handling --- rewrite/caches/cache.ts | 4 +- rewrite/commandManager.ts | 84 ++++++++++-- rewrite/context.ts | 1 + rewrite/discordAPI.ts | 4 + rewrite/eventProcessor.ts | 16 ++- rewrite/gatewayManager.ts | 213 +++++++++++++++++++++++++----- rewrite/hydrator.ts | 27 ++-- rewrite/index.ts | 26 +++- rewrite/types/common.ts | 7 +- rewrite/utils.ts | 2 +- testing/inferface.rewrite.test.ts | 13 +- 11 files changed, 313 insertions(+), 84 deletions(-) diff --git a/rewrite/caches/cache.ts b/rewrite/caches/cache.ts index 285d40a..e0d39cd 100644 --- a/rewrite/caches/cache.ts +++ b/rewrite/caches/cache.ts @@ -16,8 +16,8 @@ export class UnicordObjectCache { this.cache = new LRUCache(maxCapacity); this.type = type; for (const event of events) { - this.self.onEvent(event, async (_context, e) => { - this.cache.set(e.id, factory[Trait.fromDiscord](e)); + this.self.onSystemEvent(event, async (payload) => { + this.cache.set(payload.id, factory[Trait.fromDiscord](payload)); }); } } diff --git a/rewrite/commandManager.ts b/rewrite/commandManager.ts index a74447d..5fd7aec 100644 --- a/rewrite/commandManager.ts +++ b/rewrite/commandManager.ts @@ -4,13 +4,11 @@ import type { UnicordArgumentDefinition, UnicordCommandHandler, UnicordCommandOptions, - UnicordHandler, } from '.'; 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; @@ -56,19 +54,59 @@ export class UnicordCommandManager { args: any[], definitions: Options['args'], ): Promise> { - // Zip the args and definition together - const zipped: [UnicordArgumentDefinition, any][] = args.map((arg, i) => [definitions[i], arg]); - // Validate the args const validated: Record = {}; - // TODO: Attempt some type coresion/resolution here (mostly for text commands) - for (const [def, arg] of zipped) { - if (false) { + + if (args.length > definitions.length) { + throw new Error(`Too many arguments. Expected ${definitions.length}, received ${args.length}`); + } + + for (const [index, def] of definitions.entries()) { + const arg = args[index]; + + if (arg === undefined || arg === null || arg === '') { + if (def.required) { + throw new Error(`Missing required argument: ${def.name}`); + } + validated[def.name] = undefined; + continue; + } + + validated[def.name] = this.coerceArgument(def, arg); + } + + return Promise.resolve(validated as ArgumentTypeFromOptions); + } + + private coerceArgument(def: UnicordArgumentDefinition, value: any) { + let coerced = value; + + if (def.type === UnicordArgumentType.Integer) { + coerced = typeof value === 'number' ? value : parseInt(value, 10); + if (!Number.isInteger(coerced)) { + throw new Error(`Argument ${def.name} must be an integer`); + } + } else if (def.type === UnicordArgumentType.Number) { + coerced = typeof value === 'number' ? value : Number(value); + if (Number.isNaN(coerced)) { + throw new Error(`Argument ${def.name} must be a number`); + } + } else if (def.type === UnicordArgumentType.Boolean) { + if (typeof value === 'boolean') { + coerced = value; + } else if (value === 'true') { + coerced = true; + } else if (value === 'false') { + coerced = false; } else { - validated[def.name] = arg; + throw new Error(`Argument ${def.name} must be a boolean`); } } - // TODO: Fix types so we don't need to cast here - return Promise.resolve(validated as ArgumentTypeFromOptions); + + if (def.choices && !def.choices.some((choice) => choice.value === coerced)) { + throw new Error(`Invalid choice for argument ${def.name}: ${value}`); + } + + return coerced; } registerDefaultHelpCommand() { @@ -142,13 +180,35 @@ export class UnicordCommandManager { return this.self; } - // TODO: Register application commands with the DiscordAPI register( type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, event: string, handler: UnicordCommandHandler, options: Options = { args: [] } as unknown as Options, ) { + if (type === UnicordEventType.ApplicationCommands) { + const commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u; + if (!commandNameRegex.test(event)) { + throw new Error( + `${event} is not a valid application command name (1-32 characters, only letters, numbers, hyphens, and underscores)`, + ); + } + + if (!options.description || options.description.length === 0) { + throw new Error('Application commands require a description'); + } + + if (options.args && options.args.length > 25) { + throw new Error('Application commands cannot have more than 25 arguments'); + } + + for (const arg of options.args) { + if (arg.choices && arg.choices.length > 25) { + throw new Error(`Application command argument ${arg.name} cannot have more than 25 choices`); + } + } + } + if (this.has(type, event)) { this.unregister(type, event); } diff --git a/rewrite/context.ts b/rewrite/context.ts index 7de7f2f..2c281f7 100644 --- a/rewrite/context.ts +++ b/rewrite/context.ts @@ -18,6 +18,7 @@ export type UnicordCommandContextInput = message?: MessagePayload; }; + /** * Represents the context of a message in a Discord channel. * Provides methods to interact with the message and the channel it was sent in. diff --git a/rewrite/discordAPI.ts b/rewrite/discordAPI.ts index b879903..4ff01a1 100644 --- a/rewrite/discordAPI.ts +++ b/rewrite/discordAPI.ts @@ -68,6 +68,10 @@ export class UnicordDiscordAPI { return this.request('POST', path, data); } + async put(path: string, data: any): Promise { + return this.request('PUT', path, data); + } + async patch(path: string, data: any): Promise { return this.request('PATCH', path, data); } diff --git a/rewrite/eventProcessor.ts b/rewrite/eventProcessor.ts index 8d9051b..c9f7efc 100644 --- a/rewrite/eventProcessor.ts +++ b/rewrite/eventProcessor.ts @@ -1,7 +1,8 @@ import { EventEmitter } from 'node:events'; import type { Unicord } from '.'; import type { UnicordCommandContext } from './context'; -import type { UnicordCommandOptions, UnicordEventContext, UnicordHandler } from './types/common'; +import type { UnicordCommandOptions, UnicordEventHandler, UnicordHandler } from './types/common'; +import { UnicordEventType } from './types/common'; export class UnicordEventProcessor extends EventEmitter { constructor(private readonly self: Unicord) { @@ -16,7 +17,16 @@ export class UnicordEventProcessor extends EventEmitter { return super.off(event, handler); } - emit(event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]): boolean { - return super.emit(event, context, args); + emitCommand( + type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, + event: string, + context: UnicordCommandContext, + args: any[], + ): boolean { + return super.emit(`${type}:${event}`, context, args); + } + + emitSystem(event: string, payload: Parameters[0]): boolean { + return super.emit(`${UnicordEventType.SystemEvent}:${event}`, payload); } } diff --git a/rewrite/gatewayManager.ts b/rewrite/gatewayManager.ts index 44dd3bc..706d1ae 100644 --- a/rewrite/gatewayManager.ts +++ b/rewrite/gatewayManager.ts @@ -1,15 +1,22 @@ import { WebSocket, type Data } from 'ws'; import { Unicord } from '.'; import { UnicordCommandContext } from './context'; -import type { DiscordMessage, Payload } from './types'; -import { fromDiscord, Message, UnicordEventContext, UnicordEventType, User } from './types'; +import type { DiscordMessage, Payload, UnicordEventContext } from './types'; +import { fromDiscord, Message, UnicordEventType, User } from './types'; import { exists, isNil, isObject, log } from './utils'; export class UnicordGatewayManager { private socket: WebSocket | null = null; private heartbeat_interval: number | null = null; - private heartbeat_timeout: Timer | null = null; + private heartbeat_timeout: ReturnType | null = null; private sequence: number | null = null; + private awaiting_heartbeat_ack = false; + + private session_id: string | null = null; + private resume_gateway_url: string | null = null; + private should_resume = false; + private reconnecting = false; + private reconnect_attempts = 0; // Internal state user: User | null = null; @@ -18,6 +25,25 @@ export class UnicordGatewayManager { private onOpen = () => { log('Connected to gateway'); + this.reconnecting = false; + this.reconnect_attempts = 0; + }; + + private onClose = (code: number, reason: Buffer) => { + log('Gateway closed:', code, reason.toString()); + this.clearHeartbeat(); + + if (!this.reconnecting) { + this.reconnect(this.canResumeFromCloseCode(code)); + } + }; + + private onError = (error: Error) => { + log('Gateway error:', error); + + if (!this.reconnecting) { + this.reconnect(true); + } }; private onMessage = async (packet: Data) => { @@ -32,6 +58,10 @@ export class UnicordGatewayManager { } if (exists(data)) { + if (exists(data.s)) { + this.sequence = data.s; + } + // NOTE: Uncomment for debugging recieved event data // log('='.repeat(25)); // log('received: ', data.op); @@ -45,9 +75,19 @@ export class UnicordGatewayManager { await this.parseEvent(data); } else if (code === 1) { // Heartbeat requested - this.send({ op: 1, d: this.sequence }); - } else if (code === 9) { + this.sendHeartbeat(); + } else if (code === 7) { // Reconnect requested + this.reconnect(true); + } else if (code === 9) { + // Invalid session. Discord sends a boolean indicating whether resume is allowed. + const canResume = data.d === true; + if (!canResume) { + this.session_id = null; + this.sequence = null; + this.should_resume = false; + } + this.reconnect(canResume); } else if (code === 10) { // Hello if (!isObject(data.d)) { @@ -55,36 +95,68 @@ export class UnicordGatewayManager { } // Store the heartbeat interval this.heartbeat_interval = data.d?.heartbeat_interval; - // Kick off the heartbeat loop - this.heartbeat(); - // Identify with the gateway - this.indentify(); + // Kick off the heartbeat loop with initial jitter + this.startHeartbeat(); + // Resume or identify with the gateway + if (this.should_resume && exists(this.session_id)) { + this.resume(); + } else { + this.identify(); + } } else if (code === 11) { // Heartbeat ACK - // log('Heartbeat ACK'); + this.awaiting_heartbeat_ack = false; } } }; - private heartbeat() { - if (exists(this.heartbeat_interval)) { - // If a previous heartbeat timeout exists, clear it - if (exists(this.heartbeat_timeout)) { - clearTimeout(this.heartbeat_timeout); - } - // Send the heartbeat payload - this.send({ op: 1, d: this.sequence }); - // Set a timeout to ensure the heartbeat is sent within the interval - this.heartbeat_timeout = setTimeout(() => { - this.heartbeat(); - }, this.heartbeat_interval * Math.random()); - } else { + private startHeartbeat() { + if (!exists(this.heartbeat_interval)) { throw new Error('Heartbeat interval not set'); } + + this.clearHeartbeat(); + + const firstHeartbeatDelay = this.heartbeat_interval * Math.random(); + this.heartbeat_timeout = setTimeout(() => { + this.heartbeat(); + }, firstHeartbeatDelay); + } + + private heartbeat() { + if (!exists(this.heartbeat_interval)) { + throw new Error('Heartbeat interval not set'); + } + + if (this.awaiting_heartbeat_ack) { + log('Missed heartbeat ACK; reconnecting gateway'); + this.reconnect(true); + return; + } + + this.sendHeartbeat(); + + this.heartbeat_timeout = setTimeout(() => { + this.heartbeat(); + }, this.heartbeat_interval); + } + + private sendHeartbeat() { + this.awaiting_heartbeat_ack = true; + this.send({ op: 1, d: this.sequence }); + } + + private clearHeartbeat() { + if (exists(this.heartbeat_timeout)) { + clearTimeout(this.heartbeat_timeout); + this.heartbeat_timeout = null; + } + + this.awaiting_heartbeat_ack = false; } protected send = (payload: Partial) => { - if (isNil(this.socket)) { + if (isNil(this.socket) || this.socket.readyState !== WebSocket.OPEN) { throw new Error('Socket is not connected'); } @@ -95,7 +167,8 @@ export class UnicordGatewayManager { this.socket.send(JSON.stringify(payload)); }; - private indentify = () => { + private identify = () => { + this.should_resume = false; this.send({ op: 2, d: { @@ -110,6 +183,17 @@ export class UnicordGatewayManager { }); }; + private resume = () => { + this.send({ + op: 6, + d: { + token: this.self.config.token, + session_id: this.session_id, + seq: this.sequence, + }, + }); + }; + private async parseEvent(payload: Payload) { if (isNil(payload.t)) { throw new Error('Event name is required'); @@ -128,8 +212,13 @@ export class UnicordGatewayManager { if (event_name === 'READY') { this.user = User[fromDiscord](event_data.user); + this.session_id = event_data.session_id; + this.resume_gateway_url = event_data.resume_gateway_url; + this.should_resume = true; + } else if (event_name === 'RESUMED') { + this.should_resume = true; } else if (event_name === 'INTERACTION_CREATE') { - // await this.handleInteractionCreate(payload); + // TODO: Application commands/interactions need a redesigned context/hydration model before implementation. } else if (event_name === 'MESSAGE_CREATE') { await this.handleMessageCreate(payload); } else if (event_name === 'MESSAGE_UPDATE') { @@ -138,7 +227,7 @@ export class UnicordGatewayManager { } // TODO: Make a real UnicordEventContext class - this.self.emit(UnicordEventType.SystemEvent, event_name, event_data as UnicordEventContext, []); + this.self.emitSystem(event_name, event_data as UnicordEventContext); } private handleMessageCreate = async (payload: Payload) => { @@ -158,18 +247,25 @@ export class UnicordGatewayManager { if (payload.d.author.id === this.user.id) { return; } + if (typeof payload.d.content !== 'string') { + return; + } + // Extract the content + console.log('payload.d', payload.d); + console.log('content', payload.d.content); const content = payload.d.content.trim(); const hasPrefixInConfig = exists(this.self.config.prefix) && this.self.config.prefix.length > 0; const userMentionPrefix = `<@${this.user.id}>`; + // Is this a command? if ( - (hasPrefixInConfig && content.startsWith(this.self.config.prefix)) || + (hasPrefixInConfig && content.startsWith(this.self.config.prefix!)) || content.startsWith(userMentionPrefix) ) { // Determine the prefix const prefix = - hasPrefixInConfig && content.startsWith(this.self.config.prefix) + hasPrefixInConfig && content.startsWith(this.self.config.prefix!) ? this.self.config.prefix! : userMentionPrefix; // Parse command @@ -177,6 +273,7 @@ export class UnicordGatewayManager { .slice(prefix.length) .trim() .split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g); + // Ignore empty commands if (args.length === 0) { return; @@ -188,21 +285,67 @@ export class UnicordGatewayManager { const message = Message.fromDiscord(payload.d as DiscordMessage); const context: UnicordCommandContext = new UnicordCommandContext(this.self, { message }); // Dispatch event for the command - this.self.emit(UnicordEventType.ChatCommands, command, context, args); + this.self.emitCommand(UnicordEventType.ChatCommands, command, context, args); } }; - async connect() { - // Fetch the Discord gateway URL - const response = await this.self.discordAPI.request('GET', '/gateway/bot'); + private reconnect(resume: boolean) { + if (this.reconnecting) { + return; + } + + this.reconnecting = true; + this.should_resume = resume && exists(this.session_id); + this.clearHeartbeat(); + + if (exists(this.socket)) { + this.socket.removeAllListeners(); + this.socket.terminate(); + this.socket = null; + } + + const delay = this.getReconnectDelay(); + log(`Reconnecting gateway in ${delay}ms${this.should_resume ? ' with resume' : ''}`); + + setTimeout(() => { + const gatewayUrl = + this.should_resume && exists(this.resume_gateway_url) ? this.resume_gateway_url : undefined; + this.connect(gatewayUrl).catch((error) => { + log('Gateway reconnect failed:', error); + this.reconnecting = false; + this.reconnect(this.should_resume); + }); + }, delay); + } + + private getReconnectDelay() { + const baseDelay = 1_000; + const maxDelay = 60_000; + const delay = Math.min(maxDelay, baseDelay * 2 ** this.reconnect_attempts); + this.reconnect_attempts += 1; + return delay + Math.floor(Math.random() * 1_000); + } + + private canResumeFromCloseCode(code: number) { + // Discord documents these close codes as non-resumable. + return ![4004, 4010, 4011, 4012, 4013, 4014].includes(code); + } + + async connect(gatewayUrl?: string) { + // Fetch the Discord gateway URL when no explicit URL was provided. + const response = gatewayUrl + ? { url: gatewayUrl } + : await this.self.discordAPI.request<{ url: string }>('GET', '/gateway/bot'); // Append the query parameters to the URL const url = new URL(response.url); - url.searchParams.append('v', '10'); - url.searchParams.append('encoding', 'json'); + url.searchParams.set('v', '10'); + url.searchParams.set('encoding', 'json'); // Open a new WebSocket connection to the Discord gateway this.socket = new WebSocket(url); // Register event listeners this.socket.on('open', this.onOpen); this.socket.on('message', this.onMessage); + this.socket.on('close', this.onClose); + this.socket.on('error', this.onError); } } diff --git a/rewrite/hydrator.ts b/rewrite/hydrator.ts index 90043f4..c1860d3 100644 --- a/rewrite/hydrator.ts +++ b/rewrite/hydrator.ts @@ -4,7 +4,7 @@ import { UnicordCommandContext } from './context'; import type { Channel, Guild, Message } from './types'; import { exists } from './utils'; -export type Hydrateable = UnicordCommandContext; +export type UnicordHydrateable = UnicordCommandContext; export type UnicordHydratedObject> = Object & { channel: Expects; @@ -12,15 +12,6 @@ export type UnicordHydratedObject; }; -export type Hydrated> = UnicordHydratedObject< - Object, - Expectations ->; - -// export type Hydrated> = T extends UnicordCommandContext -// ? HydratedCommandContext -// : never; - // NOTE: Dont forget to update the typedoc comment in src/index.ts and src/context.ts when making changes here /** * Hydrates a given data object based on the provided expectations. @@ -33,7 +24,7 @@ export type Hydrated>( +export async function hydrate>( self: Unicord, data: T, expectations: K, @@ -45,15 +36,18 @@ export async function hydrate; + // let context = data as unknown as UnicordHydratedObject; // context.message = await self.caches.messages.get(data.channel_id, data.message_id); // } else if (expectation === Expectation.Guild && exists(data.guild_id)) { if (expectation === Expectation.Guild && exists(data.guild_id)) { - let context = data as unknown as UnicordHydratedObject; + let context = data as unknown as UnicordHydratedObject; context.guild = await self.caches.guilds.get(data.guild_id); } else if (expectation === Expectation.Channel && exists(data.channel_id)) { - let context = data as unknown as UnicordHydratedObject; + let context = data as unknown as UnicordHydratedObject; context.channel = await self.caches.channels.get(data.channel_id); + } else if (expectation === Expectation.Message && exists(data.message)) { + let context = data as unknown as UnicordHydratedObject; + context.message = data.message; } else { throw new Error(`Invalid expectation requested for Context: ${expectation}`); } @@ -62,9 +56,6 @@ export async function hydrate; + return data as T & UnicordHydratedObject; } diff --git a/rewrite/index.ts b/rewrite/index.ts index 27e6421..cd2cb5a 100644 --- a/rewrite/index.ts +++ b/rewrite/index.ts @@ -70,14 +70,24 @@ export class Unicord { return this.eventProcessor.register(`${type}:${event}`, handler); } - use(client: Unicord): this { - this.commandManager.merge(client.commandManager); - // TODO: inherit event listeners too - return this; + // This is kinda dumb maybe just remove + // use(client: Unicord): this { + // this.commandManager.merge(client.commandManager); + // // TODO: inherit event listeners too + // return this; + // } + + emitSystem(event: string, payload: UnicordEventContext) { + return this.eventProcessor.emitSystem(event, payload); } - emit(type: UnicordEventType, event: string, context: UnicordCommandContext | UnicordEventContext, args: any[]) { - this.eventProcessor.emit(`${type}:${event}`, context, args); + emitCommand( + type: UnicordEventType.ChatCommands | UnicordEventType.ApplicationCommands, + event: string, + context: UnicordCommandContext, + args: any[], + ) { + return this.eventProcessor.emitCommand(type, event, context, args); } // TODO: can we automate defining these methods with some TS magic? @@ -104,6 +114,10 @@ export class Unicord { return this; } + onSystemEvent(event: string, handler: UnicordEventHandler): void { + this.__onEvent(UnicordEventType.SystemEvent, event, handler); + } + sendMessage = async (channel_id: string, message: Message | string) => { if (typeof message === 'string') { message = new Message().setContent(message); diff --git a/rewrite/types/common.ts b/rewrite/types/common.ts index 00e0767..126163f 100644 --- a/rewrite/types/common.ts +++ b/rewrite/types/common.ts @@ -133,12 +133,9 @@ export type UnicordCommandHandler = ( args: ArgumentTypeFromOptions, ) => any; -// TODO: Fill out signature -export type UnicordEventHandler = (context: UnicordEventContext, payload: any) => any; +export type UnicordEventHandler = (payload: EventPayload) => any; -export type UnicordHandler = - | UnicordCommandHandler - | UnicordEventHandler; +export type UnicordHandler = UnicordCommandHandler | UnicordEventHandler; export type ClientConfig = { token: string; diff --git a/rewrite/utils.ts b/rewrite/utils.ts index 3457de3..64eca11 100644 --- a/rewrite/utils.ts +++ b/rewrite/utils.ts @@ -20,7 +20,7 @@ export function setDefaults(original: Record, defaults: Record value === null || value === undefined; export const exists = (value: T | null | undefined): value is NonNullable => !isNil(value); export const isObject = (value: any): value is Record => - typeof value === 'object' && !Array.isArray(value); + value !== null && typeof value === 'object' && !Array.isArray(value); export const log = (...args: any[]) => { const LOG_LEVEL = process.env.UNICORD_LOG_LEVEL?.toLocaleLowerCase(); diff --git a/testing/inferface.rewrite.test.ts b/testing/inferface.rewrite.test.ts index 4dd9ad0..ff4b873 100644 --- a/testing/inferface.rewrite.test.ts +++ b/testing/inferface.rewrite.test.ts @@ -1,7 +1,7 @@ // Here we are defining what we want the interface to look like so that there is a goal to work towards // Things here are not necessarily implemented in the actual code but show what we want it to look like to use the library -import { Expectation, Unicord, UnicordArgumentType } from '../rewrite/index.ts'; +import { Expectation, Unicord, UnicordArgumentType, Intent } from '../rewrite/index.ts'; const DISCORD_APPLICATION_ID = process.env.APPLICATION_ID!; const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!; @@ -9,8 +9,17 @@ const DISCORD_CLIENT_TOKEN = process.env.BOT_TOKEN!; // Create a new client instance // Most usage will be via chaining methods off the client instance const client = new Unicord({ + intents: [ + Intent.GUILDS, + Intent.GUILD_MESSAGES, + Intent.GUILD_MESSAGE_REACTIONS, + Intent.MESSAGE_CONTENT, + Intent.DIRECT_MESSAGES, + Intent.DIRECT_MESSAGE_REACTIONS, + ], application_id: DISCORD_APPLICATION_ID, token: DISCORD_CLIENT_TOKEN, + prefix: '!!', }) .registerChatCommand( 'ping', @@ -98,6 +107,6 @@ const etc = new Unicord().registerChatCommand( }, ); -client.use(etc); +// client.use(etc); client.login();