diff --git a/src/services/api.ts b/src/services/api.ts index 6517855..58f5a70 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -13,9 +13,6 @@ export class API { 'Content-Type': 'application/json' }; - console.log(`API Request: ${method} ${url}`); - console.log(`API Body: ${JSON.stringify(data, null, 4)}`); - const response = await fetch(url, { method, headers, @@ -30,6 +27,9 @@ export class API { const body = await response.json(); + + // console.log(`API Request: ${method} ${url}`); + // console.log(`API Body: ${JSON.stringify(data, null, 4)}`); // console.log('API Response:', JSON.stringify(body, null, 4)); return body; diff --git a/src/services/bus.ts b/src/services/bus.ts new file mode 100644 index 0000000..7f96f55 --- /dev/null +++ b/src/services/bus.ts @@ -0,0 +1,10 @@ +import { EventEmitter } from 'node:events'; +import type { Context } from '../types/context'; + +const EventBus = new EventEmitter() as Emitter; + +export type Emitter = EventEmitter & { + emit: (event_name: string, context: Context | null, ...args: any[]) => boolean +} + +export { EventBus }; \ No newline at end of file diff --git a/src/services/client.ts b/src/services/client.ts index c22c735..69283b7 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -8,7 +8,7 @@ import { Guilds } from './caches/guilds'; import { Messages } from './caches/messages'; import { Users } from './caches/users'; import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator'; -import type { User } from '../types/user'; +import { EventBus, type Emitter } from './bus'; export class Client { readonly token: string; @@ -16,6 +16,7 @@ export class Client { readonly intents: Intent[] = [Intent.DEFAULT]; prefix: string; // Internal services + protected bus: Emitter; protected api: API; protected processor: Processor; protected gateway: Gateway; @@ -34,9 +35,10 @@ export class Client { this.intents = config.intents ?? this.intents; this.prefix = config.prefix ?? '!'; // Initialize internal services + this.bus = EventBus; this.api = new API(this); - this.processor = new Processor(this, this.api); - this.gateway = new Gateway(this, this.processor, this.api); + this.processor = new Processor(this, this.api, this.bus); + this.gateway = new Gateway(this, this.processor, this.api, this.bus); this.guilds = new Guilds(this.api, this.processor); this.users = new Users(this.api, this.processor); this.messages = new Messages(this, this.api, this.processor); @@ -112,7 +114,7 @@ export class Client { message = new Message().setContent(message); } - return Message[fromDiscord](await this.api.post(`/channels/${channel_id}/messages`, message.toJSON())); + return Message.fromDiscord(await this.api.post(`/channels/${channel_id}/messages`, message.toJSON())); } // chatCommands = { @@ -127,5 +129,11 @@ export class Client { // has: (name: string) => this.processor.application_commands.has(name) // } + on = (event: string, handler: (...args: any[]) => void) => this.bus.on(event, handler); + + once = (event: string, handler: (...args: any[]) => void) => this.bus.once(event, handler); + + off = (event: string, handler: (...args: any[]) => void) => this.bus.off(event, handler); + connect = () => this.gateway.connect(); } \ No newline at end of file diff --git a/src/services/connectors/gateway.ts b/src/services/connectors/gateway.ts index 734abc7..a25a31b 100644 --- a/src/services/connectors/gateway.ts +++ b/src/services/connectors/gateway.ts @@ -1,6 +1,6 @@ import { WebSocket, type Data } from 'ws'; import type { Client } from "../client"; -import { fromDiscord, type InteractionPayload, type Payload } from "../../types/common"; +import { fromDiscord, HandlerType, type InteractionPayload, type Payload } from "../../types/common"; import { exists, isNil, isObject, log } from "../../utils"; import type { API } from "../api"; import type { Processor } from '../processor'; @@ -9,6 +9,7 @@ import { type DiscordMessage, Message } from '../../types/message'; import { DiscordUser, User } from '../../types/user'; import { Guild } from '../../types/guild'; import { Channel } from '../../types/channel'; +import type { Emitter } from '../bus'; export class Gateway { private socket: WebSocket | null = null; @@ -18,14 +19,13 @@ export class Gateway { // Internal state user: User | null = null; - constructor(private client: Client, private processor: Processor, private api: API) {} + constructor(private client: Client, private processor: Processor, private api: API, private bus: Emitter) {} private onOpen = () => { log('Connected to gateway'); } private onMessage = async (packet: Data) => { - // console.log(packet); // Allocate a variable to store the parsed payload let data: Payload | null = null; @@ -36,12 +36,13 @@ export class Gateway { console.error(e); } - if (data) { - log('='.repeat(25)); - log('received: ', data.op); - log('received event: ', data.t ?? 'no event'); - log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data'); - log('='.repeat(25)); + if (exists(data)) { + // NOTE: Uncomment for debugging recieved event data + // log('='.repeat(25)); + // log('received: ', data.op); + // log('received event: ', data.t ?? 'no event'); + // log('received data: ', exists(data.d) ? Object.keys(data.d) : 'no data'); + // log('='.repeat(25)); // If the data is successfully parsed check the opcode const code = data.op; if (code === 0) { @@ -61,6 +62,9 @@ export class Gateway { this.heartbeat_interval = data.d?.heartbeat_interval; // Kick off the heartbeat loop this.heartbeat(); + // Identify with the gateway + // TODO: Is there a way identify without the timeout? Or waiting for the heartbeat? + // We need to wait a bit before sending the identify payload so that the heartbeat can be established setTimeout(this.indentify, 2500); } else if (code === 11) { // Heartbeat ACK @@ -70,7 +74,6 @@ export class Gateway { } private heartbeat() { - log('Sending heartbeat'); if (exists(this.heartbeat_interval)) { // If a previous heartbeat timeout exists, clear it if (exists(this.heartbeat_timeout)) { @@ -91,9 +94,9 @@ export class Gateway { throw new Error('Socket is not connected'); } - log('='.repeat(25)); - log('sending: ', JSON.stringify(payload, null, 4)); - log('='.repeat(25)); + // log('='.repeat(25)); + // log('sending: ', JSON.stringify(payload, null, 4)); + // log('='.repeat(25)); this.socket.send(JSON.stringify(payload)); }; @@ -118,15 +121,18 @@ export class Gateway { throw new Error('Event name is required'); } - log('Event:', payload.t); + // log('Event:', payload.t); + // Extract the event name and data const event_name = payload.t; const event_data = payload.d; + // Simple validation on the event data if (!isObject(event_data)) { throw new Error('Invalid event data received'); } + // Check if this is a known event if (event_name === 'READY') { this.user = User[fromDiscord](DiscordUser.fromAPIResponse(event_data.user)); } else if (event_name === 'INTERACTION_CREATE') { @@ -140,6 +146,7 @@ export class Gateway { // Allocate a var to store the context let context = null; + // If the event is a message event, create a context from the message if (['MESSAGE_CREATE', 'MESSAGE_UPDATE'].includes(event_name)) { // If the event is a message event, create a message object const message = Message[fromDiscord](event_data as DiscordMessage); @@ -148,7 +155,8 @@ export class Gateway { } // Call event handlers - this.processor.events.execute(event_name, context, event_data); + this.bus.emit(event_name, context, event_data); + // this.processor.events.execute(event_name, context, event_data); } private handleMessageCreate = async (payload: Payload) => { @@ -169,7 +177,7 @@ export class Gateway { return; } // Extract the author - const author = User[fromDiscord](DiscordUser.fromAPIResponse(payload.d.author)); + const author = User.fromDiscord(DiscordUser.fromAPIResponse(payload.d.author)); // Store the author in the cache this.client.users.set(author.id, author); // Extract the content @@ -191,13 +199,15 @@ export class Gateway { // TODO: Case insensitive config? const command = args.shift()!; // Create a new message object from the payload - const message = Message[fromDiscord](payload.d as DiscordMessage); + const message = Message.fromDiscord(payload.d as DiscordMessage); + // Store the message in the cache + this.client.messages.set(message.id, message); // Generate context const context = new Context(this.client, this.api, { message }); - // Check if command exists - if (this.processor.chat_commands.has(command)) { + // // Check if command exists + if (this.processor[HandlerType.ChatCommands].has(command)) { // Execute the handler - this.processor.chat_commands.execute(command, context, args); + this.processor[HandlerType.ChatCommands].execute(command, context, args); } else { // TODO: Custom unknown command handler context.reply(`Unknown command: ${command}`); diff --git a/src/services/processor.ts b/src/services/processor.ts index c092d6a..65766c1 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -4,6 +4,7 @@ import { HandlerType, Trait, type EventPayload } from "../types/common"; import { CommandHandler, type Handler, type ArgsFromOptions, OptionTypeMap } from "../types/handler"; import type { API } from "./api"; import { ApplicationCommandType, type ApplicationCommandOption, type ApplicationCommandOptionResult } from "../types/applicationCommand"; +import type { Emitter } from "./bus"; export class Processor { private handlers = { @@ -13,7 +14,7 @@ export class Processor { [HandlerType.Interactions]: new Map() }; - constructor(private client: Client, private api: API) {}; + constructor(private client: Client, private api: API, private bus: Emitter) {}; public register(type: HandlerType, event: string | string[], handler: Handler) { // Normalize events into an array @@ -76,6 +77,7 @@ export class Processor { const zipped: [ApplicationCommandOption, ApplicationCommandOptionResult][] = args.map((arg, i) => [definition[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 (def.choices) { // If the definition has choices, validate the arg against them @@ -125,7 +127,10 @@ export class Processor { // Ensure the command name is valid // @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming - if (!names.every(/^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u.test.bind(/^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u))) { + // TODO: can this sus bind be removed? Is the only way to kill the arrow function? + const commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u; + const commandNameTest = commandNameRegex.test.bind(commandNameRegex) + if (!names.every(commandNameTest)) { throw new Error('Invalid application command name'); }; diff --git a/src/types/base.ts b/src/types/base.ts new file mode 100644 index 0000000..4a768db --- /dev/null +++ b/src/types/base.ts @@ -0,0 +1,20 @@ +import { Trait } from "./common"; + +export abstract class GatewayObject { + constructor() {} + + // TODO: Add a clone method to subclasses + // public abstract [Trait.clone](this: new (data: any) => T): T; + + // public get clone() { + // return this[Trait.clone]; + // } + + static [Trait.fromDiscord](this: new (...args: any[]) => T, data: any): T { + throw new Error('Method should be overridden - you should not see this. If you do, please report it.'); + }; + + static get fromDiscord() { + return this[Trait.fromDiscord]; + } +} \ No newline at end of file diff --git a/src/types/common.ts b/src/types/common.ts index a353d91..a8079a9 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -21,13 +21,16 @@ export enum HandlerType { export type Expects, K extends Expectation> = Extract extends never ? false : true; +export const clone: unique symbol = Symbol('clone') export const fromDiscord: unique symbol = Symbol('fromDiscord') export const execute: unique symbol = Symbol('execute') export const Trait: { + clone: typeof clone fromDiscord: typeof fromDiscord execute: typeof execute } = { + clone, fromDiscord, execute } diff --git a/src/types/context.ts b/src/types/context.ts index a11b9a5..4b30ded 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -29,20 +29,19 @@ export type HydratedContext> = Context & { message: Extract extends never ? undefined : Message; }; -type ContextData = +export type ContextData = | { channel_id?: string; message_id?: string; guild_id?: string; message?: MessagePayload, interaction: InteractionPayload } - | { channel_id?: string; message_id?: string; guild_id?: string; message: MessagePayload, interaction?: InteractionPayload } + | { channel_id?: string; message_id?: string; guild_id?: string; message: MessagePayload, interaction?: InteractionPayload }; -export class Context { +export class Context { // Public properties message_id?: string; channel_id: string; guild_id?: string; - message?: MessagePayload; - // TODO: Type this - interaction?: InteractionPayload; + message?: D extends { message: MessagePayload } ? MessagePayload : undefined; + interaction?: D extends { interaction: InteractionPayload } ? InteractionPayload : undefined; - constructor(private client: Client, private api: API, data: ContextData) { + constructor(private client: Client, private api: API, data: D) { const channel_id = data.channel_id ?? data.message?.channel_id ?? data.interaction?.channel_id; if (isNil(channel_id)) { @@ -53,8 +52,8 @@ export class Context { this.message_id = data.message_id ?? data.message?.id; this.channel_id = channel_id; this.guild_id = data.guild_id ?? data.message?.guild_id ?? data.interaction?.guild_id; - this.message = data.message; - this.interaction = data.interaction; + this.message = data.message as D extends { message: MessagePayload } ? MessagePayload : undefined; + this.interaction = data.interaction as D extends { interaction: InteractionPayload } ? InteractionPayload : undefined; } get self() { @@ -104,6 +103,11 @@ export class Context { */ public reply = async (message: Message | string, reference: boolean = true): Promise => { console.log('this.interaction', this.interaction) + + if (isNil(this.message)) { + throw new Error('Cannot reply without a message'); + } + if (typeof message === 'string') { message = new Message().setContent(message); } diff --git a/src/types/handler.ts b/src/types/handler.ts index 3c3cac0..221a684 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -17,7 +17,7 @@ export const OptionTypeMap = { }; // Recursive type to map ApplicationCommandOption to arguments -type ArgsFromOptions = { +export type ArgsFromOptions = { [Option in T[number] as Option['name']]: Option extends { options: readonly ApplicationCommandOption[] } ? ArgsFromOptions // Recursive inference for subcommands : Option['choices'] extends readonly { value: infer U }[] @@ -33,16 +33,26 @@ type ArgsFromOptions = { : unknown | undefined; }; +export type CommandHandlerExecuteFunction = (context: Context, args: ArgsFromOptions) => Promise | void; + export type CommandHandlerInput = { args: T; description: string; - [Trait.execute]: (context: Context, args: ArgsFromOptions) => Promise | void; + execute: CommandHandlerExecuteFunction; }; export class CommandHandler { args: T; description: string; - [Trait.execute]!: (context: Context, args: ArgsFromOptions) => Promise | void; + [Trait.execute]!: CommandHandlerExecuteFunction; + + get execute() { + return this[Trait.execute]; + } + + set execute(fn: CommandHandlerExecuteFunction) { + this[Trait.execute] = fn; + } constructor(input: CommandHandlerInput) { // Validate option names @@ -54,7 +64,7 @@ export class CommandHandler { } this.args = input.args; this.description = input.description - this[Trait.execute] = input[Trait.execute]; + this[Trait.execute] = input.execute; } toJSON() { @@ -74,5 +84,7 @@ export function createCommandHandler Promise | void; export type EventHandlerFunction = (context: Context | null, payload: EventPayload) => Promise | void; -export type HandlerFunction = CommandHandlerFunction | EventHandlerFunction; -export type Handler = HandlerFunction | CommandHandler; +export type HandlerWithoutContext = EventHandlerFunction; +export type HanderWithContext = CommandHandlerFunction | CommandHandler; + +export type Handler = EventHandlerFunction | CommandHandlerFunction | CommandHandler; diff --git a/src/types/message.ts b/src/types/message.ts index 5767087..acc1a96 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -1,4 +1,5 @@ import { isNil } from '../utils'; +import { GatewayObject } from './base'; import { fromDiscord, Trait, type Expectation, type Expects } from './common'; import { Embed } from './embed'; import type { Guild } from './guild'; @@ -102,7 +103,7 @@ export type MessagePayload = Message & { timestamp: Date; }; -export class Message { +export class Message extends GatewayObject { id?: string; author?: User; channel_id?: string; @@ -118,15 +119,16 @@ export class Message { timestamp?: Date; constructor(data?: Partial) { + super(); this.id = data?.id; this.author = data?.author; this.channel_id = data?.channel_id; this.guild_id = data?.guild_id; this.content = data?.content ?? ''; this.timestamp = data?.timestamp; - } + }; - public static [Trait.fromDiscord](data: DiscordMessage): MessagePayload { + public static [Trait.fromDiscord](data: DiscordMessage): T { return new Message({ id: data.id, author: User[fromDiscord](data.author), @@ -134,22 +136,22 @@ export class Message { guild_id: data.guild_id, content: data.content, timestamp: new Date(data.timestamp) - }) as MessagePayload; - } + }) as T; + }; public static button(config: Button): Component { return { type: ComponentType.Button, ...config }; - } + }; public static selectMenu(config: SelectMenu): Component { return { type: ComponentType.SelectMenu, ...config }; - } + }; public setContent = (content: string) => { this.content = content; diff --git a/src/types/user.ts b/src/types/user.ts index 10715ef..6df7db6 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,6 +1,7 @@ +import { GatewayObject } from "./base"; import { Trait } from "./common"; -export class User { +export class User extends GatewayObject { id: string; username: string; discriminator: string; @@ -17,6 +18,7 @@ export class User { // TODO: Add the rest of the properties constructor(data: Partial & { id: string; username: string; discriminator: string; display_name: string; avatar: string }) { + super(); this.id = data.id; this.username = data.username; this.discriminator = data.discriminator; @@ -32,7 +34,10 @@ export class User { this.email = data.email; } - public static [Trait.fromDiscord](data: DiscordUser): User { + // TODO: Add a clone method to subclasses + // public [Trait.clone](data: DiscordUser): T { ... }; + + public static [Trait.fromDiscord](data: DiscordUser): T { return new User({ id: data.id, username: data.username, @@ -47,8 +52,8 @@ export class User { locale: data.locale, verified: data.verified, email: data.email - }); - } + }) as T; + }; } // id snowflake the user's id identify diff --git a/testing/demo.test.ts b/testing/demo.test.ts index f453a90..7d485ab 100644 --- a/testing/demo.test.ts +++ b/testing/demo.test.ts @@ -1,4 +1,4 @@ -import { Client, type Context, createCommandHandler, Expectation, Intent, type MessagePayload, Trait } from '../src/index'; +import { Client, type Context, createCommandHandler, Expectation, Intent, type MessagePayload } from '../src/index'; import { ApplicationCommandOptionType, ApplicationCommandType } from '../src/types/applicationCommand'; const client = new Client({ @@ -13,14 +13,22 @@ const client = new Client({ prefix: '??' }); -client.chatCommands.register('ping', async (context: Context, args: any[]) => { - const message = await context.reply(`Pong!: got ${args.join(', ')}`, true); - // Calculate the time it took to send the message - const time = message.timestamp.getTime() - context.message.timestamp.getTime(); - // Edit the message to include the time - await context.editMessage(message, `Pong! Latency: ${time}ms`); +// Register arbitrary event handler to the client bus +client.on('READY', async (_, payload) => { + console.log(`Logged in as ${payload.user.username}${payload.user.discriminator?.length > 0 ? `#${payload.user.discriminator}` : ''}`); }); +// Register a chat command +client.chatCommands.register('ping', async (context: Context, args: any[]) => { + const { message } = await context.hydrate(context, [Expectation.Message]); + const response = await context.reply(`Pong!: got ${args.join(', ')}`, true); + // Calculate the time it took to send the message + const time = message.timestamp.getTime() - message.timestamp.getTime(); + // Edit the message to include the time + await context.editMessage(response, `Pong! Latency: ${time}ms`); +}); + +// Register an application command client.applicationCommands.register('hello', ApplicationCommandType.Chat, createCommandHandler({ description: "Introduce yourself", args: [ @@ -56,7 +64,7 @@ client.applicationCommands.register('hello', ApplicationCommandType.Chat, create ] } ] as const, - [Trait.execute]: async (context, args) => { + execute: async (context, args) => { context.reply( `\ Hello ${args.name}! I see you are ${args.age} years old${!!args.favorite_animal ? ` and your favorite animal is ${args.favorite_animal}.`: '.'} @@ -65,9 +73,11 @@ Nice to meet you, I'm ${context.self.username}!` } })); +// Repeated hydration client.chatCommands.register('me', async (context: Context, _args: any[]) => { // Note: Testing repeated hydration - const hydrated = await context.hydrate(context.message, [Expectation.Channel]) + const { message } = await context.hydrate(context, [Expectation.Message]) + const hydrated = await context.hydrate(message, [Expectation.Channel]) const rehydrated = await context.hydrate(hydrated, [Expectation.Guild]); rehydrated.guild.name @@ -84,6 +94,7 @@ client.chatCommands.register('me', async (context: Context, _args: any[]) => { // await context.reply(`You are ${message.author.username} ${hasGuild ? message.guild.name : ''}`, true); }); +// Hydrator - hydration function generator client.chatCommands.register('here', async (context: Context, _args: any[]) => { const guild = (await context.hydrator(context, [Expectation.Guild]))(context); @@ -94,8 +105,9 @@ client.chatCommands.register('here', async (context: Context, _args: any[]) => { } }); +// Quote command client.chatCommands.register('quote', async (context: Context, _args: any[]) => { - const message = context.message; + const { message } = await context.hydrate(context, [Expectation.Message]); const reference = (await context.hydrator(message, [Expectation.Message]))(message); let reply: MessagePayload;