diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e0490ef --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +src/services/processor.ts \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index e1ce067..749703e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,5 +1,5 @@ -import { exists, log } from "../utils"; -import type { Client } from "./client"; +import { exists, log } from '../utils'; +import type { Client } from './client'; export class API { private readonly base_url: string = 'https://discord.com/api/v10'; @@ -9,9 +9,9 @@ export class API { async request(method: string, path: string, data?: any): Promise { const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`; const headers = { - Authorization: `Bot ${this.client.token}`, - 'Accept': 'application/json', - 'Content-Type': 'application/json' + Authorization: `Bot ${this.client.config.token}`, + Accept: 'application/json', + 'Content-Type': 'application/json', }; const response = await fetch(url, { @@ -28,7 +28,6 @@ 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)); @@ -49,7 +48,7 @@ export class API { const retryAfter = response.headers.get('Retry-After'); if (exists(retryAfter)) { // Wait for the retry-after time - await new Promise(resolve => setTimeout(resolve, parseInt(retryAfter) * 1000)); + await new Promise((resolve) => setTimeout(resolve, parseInt(retryAfter) * 1000)); } else { // TODO: Handle this case better // Maybe wait some arbitrary time?? @@ -75,4 +74,4 @@ export class API { async delete(path: string): Promise { return this.request('DELETE', path); } -} \ No newline at end of file +} diff --git a/src/services/client.ts b/src/services/client.ts index 77753d7..082d143 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -12,44 +12,42 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator'; import { Processor } from './processor'; export class Client { - readonly token: string; - readonly application_id: string; - readonly intents: Intent[] = [Intent.DEFAULT]; - prefix: string; + readonly config: Readonly>; // Internal services protected bus: Emitter; protected api: API; protected processor: Processor; protected gateway: Gateway; - guilds: Guilds; - users: Users; - messages: Messages; - channels: Channels; - chatCommands: Processor[HandlerType.ChatCommands]; - applicationCommands: Processor[HandlerType.ApplicationCommands]; - interactions: Processor[HandlerType.Interactions]; + // Caches + caches: { + guilds: Guilds; + users: Users; + messages: Messages; + channels: Channels; + }; constructor(config: ClientConfig) { // Validate the configuration Client.validateConfig(config); // Save the configuration - this.token = config.token; - this.application_id = config.application_id; - this.intents = config.intents ?? this.intents; - this.prefix = config.prefix ?? '!'; + this.config = Object.freeze({ + token: config.token, + application_id: config.application_id, + intents: config.intents ?? [Intent.DEFAULT], + prefix: config.prefix ?? '!', + }); // Initialize internal services this.bus = EventBus; this.api = new API(this); 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.api, this.processor); - this.channels = new Channels(this.api, this.processor); - // Save references to command and event handlers - this.chatCommands = this.processor[HandlerType.ChatCommands]; - this.applicationCommands = this.processor[HandlerType.ApplicationCommands]; - this.interactions = this.processor[HandlerType.Interactions]; + // Initialize caches + this.caches = { + guilds: new Guilds(this.api, this.processor), + users: new Users(this.api, this.processor), + messages: new Messages(this.api, this.processor), + channels: new Channels(this.api, this.processor), + }; } private static validateConfig(config: ClientConfig) { @@ -82,6 +80,22 @@ export class Client { return this.gateway.user; } + get chatCommands() { + return this.processor[HandlerType.ChatCommands]; + } + + get applicationCommands() { + return this.processor[HandlerType.ApplicationCommands]; + } + + get interactions() { + return this.processor[HandlerType.Interactions]; + } + + get events() { + return this.processor[HandlerType.Events]; + } + /** * Hydrates a given data object based on the provided expectations. * diff --git a/src/services/connectors/gateway.ts b/src/services/connectors/gateway.ts index 241b9c5..42a03d1 100644 --- a/src/services/connectors/gateway.ts +++ b/src/services/connectors/gateway.ts @@ -1,8 +1,8 @@ import { WebSocket, type Data } from 'ws'; -import type { Client } from "../client"; -import { fromDiscord, HandlerType, type Payload } from "../../types/common"; -import { exists, isNil, isObject, log } from "../../utils"; -import type { API } from "../api"; +import type { Client } from '../client'; +import { fromDiscord, HandlerType, type Payload } from '../../types/common'; +import { exists, isNil, isObject, log } from '../../utils'; +import type { API } from '../api'; import type { Processor } from '../processor'; import { Context } from '../../types/context'; import { type DiscordMessage, Message } from '../../types/message'; @@ -23,7 +23,7 @@ export class Gateway { private onOpen = () => { log('Connected to gateway'); - } + }; private onMessage = async (packet: Data) => { // Allocate a variable to store the parsed payload @@ -71,7 +71,7 @@ export class Gateway { log('Heartbeat ACK'); } } - } + }; private heartbeat() { if (exists(this.heartbeat_interval)) { @@ -105,14 +105,14 @@ export class Gateway { this.send({ op: 2, d: { - token: this.client.token, - intents: this.client.intents.reduce((acc, intent) => acc | intent, 0), + token: this.client.config.token, + intents: this.client.config.intents.reduce((acc, intent) => acc | intent, 0), properties: { os: process.platform, browser: 'unicord', - device: 'unicord' - } - } + device: 'unicord', + }, + }, }); }; @@ -179,13 +179,15 @@ export class Gateway { // Extract the author const author = User.fromDiscord(DiscordUser.fromAPIResponse(payload.d.author)); // Store the author in the cache - this.client.users.set(author.id, author); + this.client.caches.users.set(author.id, author); // Extract the content const content = payload.d.content.trim(); // Is this a command? - if (content.startsWith(this.client.prefix) || content.startsWith(`<@${this.user.id}>`)) { + if (content.startsWith(this.client.config.prefix) || content.startsWith(`<@${this.user.id}>`)) { // Determine the prefix - const prefix = content.startsWith(this.client.prefix) ? this.client.prefix : `<@${this.user.id}> `; + const prefix = content.startsWith(this.client.config.prefix) + ? this.client.config.prefix + : `<@${this.user.id}> `; // Parse command const args = content .slice(prefix.length) @@ -201,7 +203,7 @@ export class Gateway { // Create a new message object from the payload const message = Message.fromDiscord(payload.d as DiscordMessage); // Store the message in the cache - this.client.messages.set(message.id, message); + this.client.caches.messages.set(message.id, message); // Generate context const context = new Context(this.client, this.api, { message }); // Dispatch event for the command @@ -231,15 +233,21 @@ export class Gateway { } if (payload.d.type === 2) { - this.handleShashInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommandData } }); + this.handleShashInteraction( + payload as Payload & { d: InteractionPayload & { data: InteractionCommandData } }, + ); } else if (payload.d.type === 3) { - this.handleChatInteraction(payload as Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }); + this.handleChatInteraction( + payload as Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }, + ); } else { // this.handleChatInteraction(payload); } }; - private handleShashInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } }) => { + private handleShashInteraction = ( + payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } }, + ) => { if (isNil(payload.d.data)) { throw new Error('Invalid INTERACTION_CREATE data received'); } @@ -251,17 +259,17 @@ export class Gateway { if (payload.d.guild) { const guild = Guild.fromDiscord(payload.d.guild); - this.client.guilds.set(guild.id, guild); + this.client.caches.guilds.set(guild.id, guild); } if (payload.d.member) { const member = User.fromDiscord(payload.d.member?.user); - this.client.users.set(member.id, member); + this.client.caches.users.set(member.id, member); } if (payload.d.user) { const user = User.fromDiscord(payload.d.user); - this.client.users.set(user.id, user); + this.client.caches.users.set(user.id, user); } // TODO: Fix when channel class is implemented @@ -271,14 +279,20 @@ export class Gateway { // } if (this.processor.application_commands.has(command)) { - this.processor.application_commands.execute(command, context, args?.map(arg => arg.value)); + this.processor.application_commands.execute( + command, + context, + args?.map((arg) => arg.value), + ); } else { // TODO: Custom unknown command handler context.reply(`Unknown command: ${command}`); } }; - private handleChatInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }) => { + private handleChatInteraction = ( + payload: Payload & { d: InteractionPayload & { data: InteractionCommpoentData } }, + ) => { const context = new Context(this.client, this.api, { interaction: payload.d }); if (exists(payload.d.data)) { @@ -305,4 +319,4 @@ export class Gateway { this.socket.on('open', this.onOpen); this.socket.on('message', this.onMessage); } -} \ No newline at end of file +} diff --git a/src/services/hydrator.ts b/src/services/hydrator.ts index 1e50c1b..ead25c9 100644 --- a/src/services/hydrator.ts +++ b/src/services/hydrator.ts @@ -41,13 +41,13 @@ export async function hydrate; - context.message = await client.messages.get(data.channel_id, data.message_id); + context.message = await client.caches.messages.get(data.channel_id, data.message_id); } else if (expectation === Expectation.Guild && exists(data.guild_id)) { let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>; - context.guild = await client.guilds.get(data.guild_id); + context.guild = await client.caches.guilds.get(data.guild_id); } else if (expectation === Expectation.Channel && exists(data.channel_id)) { let context = data as unknown as HydratedContext<[Expectation.Channel, ...Expectation[]]>; - context.channel = await client.channels.get(data.channel_id); + context.channel = await client.caches.channels.get(data.channel_id); } else { throw new Error(`Invalid expectation requested for Context: ${expectation}`); } @@ -56,13 +56,13 @@ export async function hydrate; - message.reference = await client.messages.get(data.message_reference.channel_id, data.message_reference.id); + message.reference = await client.caches.messages.get(data.message_reference.channel_id, data.message_reference.id); } else if (expectation === Expectation.Guild && exists(data.guild_id)) { let message = data as unknown as HydratedMessage<[Expectation.Guild, ...Expectation[]]>; - message.guild = await client.guilds.get(data.guild_id); + message.guild = await client.caches.guilds.get(data.guild_id); } else if (expectation === Expectation.Channel && exists(data.channel_id)) { let message = data as unknown as HydratedMessage<[Expectation.Channel, ...Expectation[]]>; - message.channel = await client.channels.get(data.channel_id); + message.channel = await client.caches.channels.get(data.channel_id); } else { throw new Error(`Invalid expectation requested for Message: ${expectation}`); } diff --git a/src/services/processor.ts b/src/services/processor.ts index 7d2fbe8..b9f214f 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -1,10 +1,10 @@ -import type { Client } from "./client"; -import type { Context } from "../types/context"; -import { HandlerType, Trait, type EventPayload } from "../types/common"; -import { CommandHandler, type Handler, type ArgsFromOptions, OptionConstructorMap, type InteractionData } from "../types/handler"; -import type { API } from "./api"; import { ApplicationCommandOptionType, ApplicationCommandType, type ApplicationCommandOption } from "../types/applicationCommand"; +import { HandlerType, Trait, type EventPayload } from "../types/common"; +import type { Context } from "../types/context"; +import { CommandHandler, OptionConstructorMap, type ArgsFromOptions, type Handler, type InteractionData } from "../types/handler"; +import type { API } from "./api"; import type { Emitter } from "./bus"; +import type { Client } from "./client"; export class Processor { private handlers = { @@ -14,13 +14,13 @@ export class Processor { [HandlerType.Interactions]: new Map() }; - constructor(private client: Client, private api: API, private bus: Emitter) {}; + constructor(private client: Client, private api: API, private bus: Emitter) { }; public register(type: HandlerType, event: string | string[], handler: Handler) { // Normalize events into an array const events = Array.isArray(event) ? event : [event]; // Register the handler for each event - for (const e of events) { + for (const e of events) { const handlers = this.handlers[type].get(e) || []; handlers.push(handler); this.handlers[type].set(e, handlers); @@ -78,7 +78,7 @@ export class Processor { } } } - + private validateAndResolveArgs = async (args: any[], definition: T): Promise> => { // Zip the args and definition together const zipped: [ApplicationCommandOption, any][] = args.map((arg, i) => [definition[i], arg]); @@ -102,11 +102,11 @@ export class Processor { if (!userId) { throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`); } - validated[def.name] = await this.client.users.get(userId); + validated[def.name] = await this.client.caches.users.get(userId); } else if (typeof arg === 'object') { // Check if the object has an ID if (arg.id) { - validated[def.name] = await this.client.users.get(arg.id); + validated[def.name] = await this.client.caches.users.get(arg.id); } else { throw new Error(`Invalid user for ${def.name}: expected a user object with an ID, got ${arg}`); } @@ -138,7 +138,7 @@ export class Processor { register: (event: string | string[], handler: Handler) => this.register(HandlerType.ChatCommands, event, handler), unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ChatCommands, event, handler), execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.ChatCommands, event, context, args), - has: (event: string) => this.handlers[HandlerType.ChatCommands].has(event) ?? this.handlers[HandlerType.ChatCommands].get(event)?.length! > 0 + has: (event: string) => this.handlers[HandlerType.ChatCommands].has(event) && this.handlers[HandlerType.ChatCommands].get(event)?.length! > 0 }; [HandlerType.ApplicationCommands] = { @@ -154,7 +154,7 @@ export class Processor { // Ensure the command name is valid // @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming - // TODO: can this sus bind be removed? Is the only way to kill the arrow function? + // TODO: can this sus bind be removed? Is the only way to convert from arrow to regular 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)) { @@ -166,21 +166,21 @@ export class Processor { const command: Record = handler.toJSON(); command.name = n; command.type = type; - this.api.post(`/applications/${this.client.application_id}/commands`, command); + this.api.post(`/applications/${this.client.config.application_id}/commands`, command); } this.register(HandlerType.ApplicationCommands, names, handler) }, unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ApplicationCommands, event, handler), execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.ApplicationCommands, event, context, args), - has: (event: string) => this.handlers[HandlerType.ApplicationCommands].has(event) ?? this.handlers[HandlerType.ApplicationCommands].get(event)?.length! > 0, + has: (event: string) => this.handlers[HandlerType.ApplicationCommands].has(event) && this.handlers[HandlerType.ApplicationCommands].get(event)?.length! > 0, }; [HandlerType.Interactions] = { register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler), unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Interactions, event, handler), execute: async (event: string, context: Context, args: InteractionData) => this.execute(HandlerType.Interactions, event, context, args), - has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0 + has: (event: string) => this.handlers[HandlerType.Interactions].has(event) && this.handlers[HandlerType.Interactions].get(event)?.length! > 0 }; static isCommandHandler = (handler: Handler): handler is CommandHandler => {