feat: Refactor client and API structure to use config object cache getters

This commit is contained in:
2025-10-14 00:39:28 -05:00
parent 3da661743c
commit 215b10e608
6 changed files with 104 additions and 76 deletions
+1
View File
@@ -0,0 +1 @@
src/services/processor.ts
+6 -7
View File
@@ -1,5 +1,5 @@
import { exists, log } from "../utils"; import { exists, log } from '../utils';
import type { Client } from "./client"; import type { Client } from './client';
export class API { export class API {
private readonly base_url: string = 'https://discord.com/api/v10'; private readonly base_url: string = 'https://discord.com/api/v10';
@@ -9,9 +9,9 @@ export class API {
async request<T = any>(method: string, path: string, data?: any): Promise<T> { async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`; const url = `${this.base_url}${path.startsWith('/') ? '' : '/'}${path}`;
const headers = { const headers = {
Authorization: `Bot ${this.client.token}`, Authorization: `Bot ${this.client.config.token}`,
'Accept': 'application/json', Accept: 'application/json',
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}; };
const response = await fetch(url, { const response = await fetch(url, {
@@ -28,7 +28,6 @@ export class API {
const body = await response.json(); const body = await response.json();
// console.log(`API Request: ${method} ${url}`); // console.log(`API Request: ${method} ${url}`);
// console.log(`API Body: ${JSON.stringify(data, null, 4)}`); // console.log(`API Body: ${JSON.stringify(data, null, 4)}`);
// console.log('API Response:', JSON.stringify(body, 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'); const retryAfter = response.headers.get('Retry-After');
if (exists(retryAfter)) { if (exists(retryAfter)) {
// Wait for the retry-after time // 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 { } else {
// TODO: Handle this case better // TODO: Handle this case better
// Maybe wait some arbitrary time?? // Maybe wait some arbitrary time??
+37 -23
View File
@@ -12,44 +12,42 @@ import { hydrate, hydrator, type Hydrateable, type Hydrated } from './hydrator';
import { Processor } from './processor'; import { Processor } from './processor';
export class Client { export class Client {
readonly token: string; readonly config: Readonly<Required<ClientConfig>>;
readonly application_id: string;
readonly intents: Intent[] = [Intent.DEFAULT];
prefix: string;
// Internal services // Internal services
protected bus: Emitter; protected bus: Emitter;
protected api: API; protected api: API;
protected processor: Processor; protected processor: Processor;
protected gateway: Gateway; protected gateway: Gateway;
guilds: Guilds; // Caches
users: Users; caches: {
messages: Messages; guilds: Guilds;
channels: Channels; users: Users;
chatCommands: Processor[HandlerType.ChatCommands]; messages: Messages;
applicationCommands: Processor[HandlerType.ApplicationCommands]; channels: Channels;
interactions: Processor[HandlerType.Interactions]; };
constructor(config: ClientConfig) { constructor(config: ClientConfig) {
// Validate the configuration // Validate the configuration
Client.validateConfig(config); Client.validateConfig(config);
// Save the configuration // Save the configuration
this.token = config.token; this.config = Object.freeze({
this.application_id = config.application_id; token: config.token,
this.intents = config.intents ?? this.intents; application_id: config.application_id,
this.prefix = config.prefix ?? '!'; intents: config.intents ?? [Intent.DEFAULT],
prefix: config.prefix ?? '!',
});
// Initialize internal services // Initialize internal services
this.bus = EventBus; this.bus = EventBus;
this.api = new API(this); this.api = new API(this);
this.processor = new Processor(this, this.api, this.bus); this.processor = new Processor(this, this.api, this.bus);
this.gateway = new Gateway(this, this.processor, this.api, this.bus); this.gateway = new Gateway(this, this.processor, this.api, this.bus);
this.guilds = new Guilds(this.api, this.processor); // Initialize caches
this.users = new Users(this.api, this.processor); this.caches = {
this.messages = new Messages(this.api, this.processor); guilds: new Guilds(this.api, this.processor),
this.channels = new Channels(this.api, this.processor); users: new Users(this.api, this.processor),
// Save references to command and event handlers messages: new Messages(this.api, this.processor),
this.chatCommands = this.processor[HandlerType.ChatCommands]; channels: new Channels(this.api, this.processor),
this.applicationCommands = this.processor[HandlerType.ApplicationCommands]; };
this.interactions = this.processor[HandlerType.Interactions];
} }
private static validateConfig(config: ClientConfig) { private static validateConfig(config: ClientConfig) {
@@ -82,6 +80,22 @@ export class Client {
return this.gateway.user; 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. * Hydrates a given data object based on the provided expectations.
* *
+37 -23
View File
@@ -1,8 +1,8 @@
import { WebSocket, type Data } from 'ws'; import { WebSocket, type Data } from 'ws';
import type { Client } from "../client"; import type { Client } from '../client';
import { fromDiscord, HandlerType, type Payload } from "../../types/common"; import { fromDiscord, HandlerType, type Payload } from '../../types/common';
import { exists, isNil, isObject, log } from "../../utils"; import { exists, isNil, isObject, log } from '../../utils';
import type { API } from "../api"; import type { API } from '../api';
import type { Processor } from '../processor'; import type { Processor } from '../processor';
import { Context } from '../../types/context'; import { Context } from '../../types/context';
import { type DiscordMessage, Message } from '../../types/message'; import { type DiscordMessage, Message } from '../../types/message';
@@ -23,7 +23,7 @@ export class Gateway {
private onOpen = () => { private onOpen = () => {
log('Connected to gateway'); log('Connected to gateway');
} };
private onMessage = async (packet: Data) => { private onMessage = async (packet: Data) => {
// Allocate a variable to store the parsed payload // Allocate a variable to store the parsed payload
@@ -71,7 +71,7 @@ export class Gateway {
log('Heartbeat ACK'); log('Heartbeat ACK');
} }
} }
} };
private heartbeat() { private heartbeat() {
if (exists(this.heartbeat_interval)) { if (exists(this.heartbeat_interval)) {
@@ -105,14 +105,14 @@ export class Gateway {
this.send({ this.send({
op: 2, op: 2,
d: { d: {
token: this.client.token, token: this.client.config.token,
intents: this.client.intents.reduce((acc, intent) => acc | intent, 0), intents: this.client.config.intents.reduce((acc, intent) => acc | intent, 0),
properties: { properties: {
os: process.platform, os: process.platform,
browser: 'unicord', browser: 'unicord',
device: 'unicord' device: 'unicord',
} },
} },
}); });
}; };
@@ -179,13 +179,15 @@ export class Gateway {
// Extract the author // 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 // Store the author in the cache
this.client.users.set(author.id, author); this.client.caches.users.set(author.id, author);
// Extract the content // Extract the content
const content = payload.d.content.trim(); const content = payload.d.content.trim();
// Is this a command? // 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 // 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 // Parse command
const args = content const args = content
.slice(prefix.length) .slice(prefix.length)
@@ -201,7 +203,7 @@ export class Gateway {
// Create a new message object from the payload // 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 // Store the message in the cache
this.client.messages.set(message.id, message); this.client.caches.messages.set(message.id, message);
// Generate context // Generate context
const context = new Context(this.client, this.api, { message }); const context = new Context(this.client, this.api, { message });
// Dispatch event for the command // Dispatch event for the command
@@ -231,15 +233,21 @@ export class Gateway {
} }
if (payload.d.type === 2) { 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) { } 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 { } else {
// this.handleChatInteraction(payload); // this.handleChatInteraction(payload);
} }
}; };
private handleShashInteraction = (payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } }) => { private handleShashInteraction = (
payload: Payload & { d: InteractionPayload & { data: InteractionCommandData } },
) => {
if (isNil(payload.d.data)) { if (isNil(payload.d.data)) {
throw new Error('Invalid INTERACTION_CREATE data received'); throw new Error('Invalid INTERACTION_CREATE data received');
} }
@@ -251,17 +259,17 @@ export class Gateway {
if (payload.d.guild) { if (payload.d.guild) {
const guild = Guild.fromDiscord(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) { if (payload.d.member) {
const member = User.fromDiscord(payload.d.member?.user); 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) { if (payload.d.user) {
const user = User.fromDiscord(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 // TODO: Fix when channel class is implemented
@@ -271,14 +279,20 @@ export class Gateway {
// } // }
if (this.processor.application_commands.has(command)) { 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 { } else {
// TODO: Custom unknown command handler // TODO: Custom unknown command handler
context.reply(`Unknown command: ${command}`); 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 }); const context = new Context(this.client, this.api, { interaction: payload.d });
if (exists(payload.d.data)) { if (exists(payload.d.data)) {
+6 -6
View File
@@ -41,13 +41,13 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
for (const expectation of expectations) { for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_id)) { if (expectation === Expectation.Message && exists(data.message_id)) {
let context = data as unknown as HydratedContext<[Expectation.Message, ...Expectation[]]>; let context = data as unknown as HydratedContext<[Expectation.Message, ...Expectation[]]>;
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)) { } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let context = data as unknown as HydratedContext<[Expectation.Guild, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let context = data as unknown as HydratedContext<[Expectation.Channel, ...Expectation[]]>; 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 { } else {
throw new Error(`Invalid expectation requested for Context: ${expectation}`); throw new Error(`Invalid expectation requested for Context: ${expectation}`);
} }
@@ -56,13 +56,13 @@ export async function hydrate<T extends Hydrateable, K extends Array<Expectation
for (const expectation of expectations) { for (const expectation of expectations) {
if (expectation === Expectation.Message && exists(data.message_reference?.id)) { if (expectation === Expectation.Message && exists(data.message_reference?.id)) {
let message = data as unknown as HydratedMessage<[Expectation.Message, ...Expectation[]]>; let message = data as unknown as HydratedMessage<[Expectation.Message, ...Expectation[]]>;
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)) { } else if (expectation === Expectation.Guild && exists(data.guild_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Guild, ...Expectation[]]>; 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)) { } else if (expectation === Expectation.Channel && exists(data.channel_id)) {
let message = data as unknown as HydratedMessage<[Expectation.Channel, ...Expectation[]]>; 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 { } else {
throw new Error(`Invalid expectation requested for Message: ${expectation}`); throw new Error(`Invalid expectation requested for Message: ${expectation}`);
} }
+13 -13
View File
@@ -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 { 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 { Emitter } from "./bus";
import type { Client } from "./client";
export class Processor { export class Processor {
private handlers = { private handlers = {
@@ -14,7 +14,7 @@ export class Processor {
[HandlerType.Interactions]: new Map<string, Handler[]>() [HandlerType.Interactions]: new Map<string, Handler[]>()
}; };
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) { public register(type: HandlerType, event: string | string[], handler: Handler) {
// Normalize events into an array // Normalize events into an array
@@ -102,11 +102,11 @@ export class Processor {
if (!userId) { if (!userId) {
throw new Error(`Invalid user for ${def.name}: expected a user mention or snowflake id, got ${arg}`); 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') { } else if (typeof arg === 'object') {
// Check if the object has an ID // Check if the object has an ID
if (arg.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 { } else {
throw new Error(`Invalid user for ${def.name}: expected a user object with an ID, got ${arg}`); 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), register: (event: string | string[], handler: Handler) => this.register(HandlerType.ChatCommands, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(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), 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] = { [HandlerType.ApplicationCommands] = {
@@ -154,7 +154,7 @@ export class Processor {
// Ensure the command name is valid // Ensure the command name is valid
// @see https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-naming // @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 commandNameRegex = /^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$/u;
const commandNameTest = commandNameRegex.test.bind(commandNameRegex) const commandNameTest = commandNameRegex.test.bind(commandNameRegex)
if (!names.every(commandNameTest)) { if (!names.every(commandNameTest)) {
@@ -166,21 +166,21 @@ export class Processor {
const command: Record<string, any> = handler.toJSON(); const command: Record<string, any> = handler.toJSON();
command.name = n; command.name = n;
command.type = type; 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) this.register(HandlerType.ApplicationCommands, names, handler)
}, },
unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.ApplicationCommands, event, 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), 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] = { [HandlerType.Interactions] = {
register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler), register: (event: string | string[], handler: Handler) => this.register(HandlerType.Interactions, event, handler),
unregister: (event: string | string[], handler?: Handler) => this.unregister(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), 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<ApplicationCommandOption[]> => { static isCommandHandler = (handler: Handler): handler is CommandHandler<ApplicationCommandOption[]> => {