diff --git a/package.json b/package.json index dfbc3ac..b544a78 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "module": "src/index.ts", "type": "module", "scripts": { - "test": "dotenvx run -f .env -- bun run testing/demo.test.ts" + "test": "dotenvx run -f .env -- bun run testing/demo.test.ts", + "test:inspect": "dotenvx run -f .env -- bun --inspect testing/demo.test.ts" }, "devDependencies": { "@types/bun": "latest" diff --git a/src/index.ts b/src/index.ts index 4fd623f..70943db 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,15 @@ +// Base classes export { Client } from './services/client'; export { Intent } from './types/common'; export { BaseHandler, type Handler } from './types/hander'; +// Consumable types export type { ClientConfig } from './types/common'; -export type { Context } from './types/context'; \ No newline at end of file +export type { Context } from './types/context'; +// Consumable util classes +export { Guild } from './types/guild'; +export { Role } from './types/role'; +export { Member } from './types/member'; +export { User } from './types/user'; +export { Message } from './types/message'; +export { Embed } from './types/embed'; +export { Emoji } from './types/emoji'; \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index d53544a..42daa31 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -6,7 +6,7 @@ export class API { constructor(private client: Client) {} async request(method: string, path: string, data?: any): Promise { - const url = `${this.base_url}${path}`; + const url = new URL(path, this.base_url).toString(); const headers = { Authorization: `Bot ${this.client.token}`, 'Accept': 'application/json', @@ -29,7 +29,7 @@ export class API { const body = await response.json(); - console.log('API Response:', JSON.stringify(body, null, 4)); + // console.log('API Response:', JSON.stringify(body, null, 4)); return body; } diff --git a/src/services/cache.ts b/src/services/cache.ts index b968030..257bb1a 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -3,15 +3,14 @@ import { exists } from "../utils"; import type { API } from "./api"; import type { Processor } from "./processor"; - export class ObjectCache { - cache = new Map(); - private readonly type: string; + cache = new Map | T>(); + readonly type: string; - constructor(private api: API, processor: Processor, type: string, events: string[]) { + constructor(protected api: API, processor: Processor, type: string, private readonly factory: (...args: any[]) => Promise, events: string[]) { this.type = type; - processor.events.register(events, (_context: Context, e: Record) => { - this.cache.set(e.id, e as T); + processor.events.register(events, async (_context: Context, e: Record) => { + this.cache.set(e.id, factory(e)); }); } @@ -30,23 +29,22 @@ export class ObjectCache { // } // } - async fetch(object_id: string): Promise { - return this.api.get(`${this.type}/${object_id}`).then((res) => { - const object: T & { id: string } = res.data; - this.cache.set(object.id, object); - return object; - }); + async fetch(object_id: string, ..._args: any[]): Promise { + const req = this.api.get(`/${this.type}/${object_id}`).then((data) => this.factory(data)); + this.cache.set(object_id, req); + req.catch(() => this.cache.delete(object_id)); + return await req; } - get(object_id: string): T | Promise { + async get(object_id: string, ..._args: any[]): Promise { if (this.cache.has(object_id)) { - return Promise.resolve(this.cache.get(object_id)!); + return await Promise.resolve(this.cache.get(object_id)!); } else { return this.fetch(object_id); } } - list(): Map { + list(): Map | T> { return this.cache; } } diff --git a/src/services/client.ts b/src/services/client.ts index 4996237..8786246 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -4,9 +4,10 @@ import { Processor } from './processor'; import { Intent, type ClientConfig } from '../types/common'; import { exists, isNil } from '../utils/index'; import type { Handler } from '../types/hander'; -import { Message } from '../types/message'; +import { DiscordMessage, Message } from '../types/message'; import { Guilds } from './guilds'; import { Users } from './users'; +import { Messages } from './messages'; export class Client { readonly token: string; @@ -14,11 +15,12 @@ export class Client { readonly intents: Intent[] = [Intent.DEFAULT]; prefix: string; // Internal services - protected _api: API; - protected _processor: Processor; - protected _gateway: Gateway; + protected api: API; + protected processor: Processor; + protected gateway: Gateway; guilds: Guilds; users: Users; + messages: Messages constructor(config: ClientConfig) { // Validate the configuration @@ -29,23 +31,12 @@ export class Client { this.intents = config.intents ?? this.intents; this.prefix = config.prefix ?? '!'; // Initialize internal services - this._api = new API(this); - this._processor = new Processor(this); - this._gateway = new Gateway(this, this.processor, this.api); + this.api = new API(this); + this.processor = new Processor(this, this.api); + this.gateway = new Gateway(this, this.processor, this.api); this.guilds = new Guilds(this.api, this.processor); this.users = new Users(this.api, this.processor); - } - - private get api() { - return this._api; - } - - private get gateway() { - return this._gateway; - } - - private get processor() { - return this._processor; + this.messages = new Messages(this, this.api, this.processor); } private static validateConfig(config: ClientConfig) { @@ -79,13 +70,19 @@ export class Client { message = new Message().setContent(message); } - return Message.fromAPIResponse(await this.api.post(`/channels/${channel_id}/messages`, message.toJSON())); + return Message.hydrate(DiscordMessage.fromAPIResponse(await this.api.post(`/channels/${channel_id}/messages`, message.toJSON())), this, this.api); } chatCommands = { - register: (name: string | string[], handler: Handler) => this.processor.commands.register(name, handler), - unregister: (name: string | string[]) => this.processor.commands.unregister(name), - has: (name: string) => this.processor.commands.has(name) + register: (name: string | string[], handler: Handler) => this.processor.chat_commands.register(name, handler), + unregister: (name: string | string[]) => this.processor.chat_commands.unregister(name), + has: (name: string) => this.processor.chat_commands.has(name) + } + + slashCommands = { + register: (name: string, handler: Handler) => this.processor.slash_commands.register(name, handler), + unregister: (name: string) => this.processor.slash_commands.unregister(name), + has: (name: string) => this.processor.slash_commands.has(name) } connect = () => this.gateway.connect(); diff --git a/src/services/gateway.ts b/src/services/gateway.ts index 8aa3252..eb114aa 100644 --- a/src/services/gateway.ts +++ b/src/services/gateway.ts @@ -5,8 +5,8 @@ import { exists, isNil, isObject, log } from "../utils"; import type { API } from "./api"; import type { Processor } from './processor'; import { Context } from '../types/context'; -import { Message } from '../types/message'; -import { User } from '../types/user'; +import { DiscordMessage, Message } from '../types/message'; +import { DiscordUser, User } from '../types/user'; export class Gateway { private socket: WebSocket | null = null; @@ -22,7 +22,7 @@ export class Gateway { log('Connected to gateway'); } - private onMessage = (packet: Data) => { + private onMessage = async (packet: Data) => { // console.log(packet); // Allocate a variable to store the parsed payload let data: Payload | null = null; @@ -44,7 +44,7 @@ export class Gateway { const code = data.op; if (code === 0) { // Event received - this.parseEvent(data); + await this.parseEvent(data); } else if (code === 1) { // Heartbeat requested this.send({ op: 1, d: this.sequence }); @@ -111,7 +111,7 @@ export class Gateway { }); }; - private parseEvent(payload: Payload) { + private async parseEvent(payload: Payload) { if (isNil(payload.t)) { throw new Error('Event name is required'); } @@ -131,7 +131,7 @@ export class Gateway { } else if (event_name === 'INTERACTION_CREATE') { // this.handleInteractionCreate(payload); } else if (event_name === 'MESSAGE_CREATE') { - this.handleMessageCreate(payload as Payload); + await this.handleMessageCreate(payload as Payload); } else if (event_name === 'MESSAGE_UPDATE') { // this.handleMessageCreate(payload); } @@ -141,7 +141,8 @@ export class Gateway { if (['MESSAGE_CREATE', 'MESSAGE_UPDATE'].includes(event_name)) { // If the event is a message event, create a message object - const message = Message.fromAPIResponse(event_data); + const discord_message = DiscordMessage.fromAPIResponse(event_data); + const message = await Message.hydrate(discord_message, this.client, this.api); // And then generate the context context = new Context(this.client, this.api, message); } @@ -164,7 +165,7 @@ export class Gateway { return; } // Extract the author - const author = User.fromAPIResponse(payload.d.author); + const author = User.hydrate(DiscordUser.fromAPIResponse(payload.d.author)); // Store the author in the cache this.client.users.set(author.id, author); // Extract the content @@ -185,14 +186,15 @@ export class Gateway { // Extract the command name // TODO: Case insensitive config? const command = args.shift()!; + console.log('payload.d', payload.d) // Create a new message object from the payload - const message = Message.fromAPIResponse(payload.d); + const message = await Message.hydrate(DiscordMessage.fromAPIResponse(payload.d), this.client, this.api); // Generate context const context = new Context(this.client, this.api, message); // Check if command exists - if (this.processor.commands.has(command)) { + if (this.processor.chat_commands.has(command)) { // Execute the handler - this.processor.commands.execute(command, context, args); + this.processor.chat_commands.execute(command, context, args); } else { // TODO: Custom unknown command handler context.reply(`Unknown command: ${command}`); diff --git a/src/services/guilds.ts b/src/services/guilds.ts index 8c57ba8..433fa9d 100644 --- a/src/services/guilds.ts +++ b/src/services/guilds.ts @@ -1,10 +1,10 @@ -import type { Guild } from "../types/guild"; +import { Guild } from "../types/guild"; import type { API } from "./api"; import { ObjectCache } from "./cache"; import type { Processor } from "./processor"; export class Guilds extends ObjectCache { constructor (api: API, processor: Processor) { - super(api, processor, 'guilds', ['GUILD_CREATE', 'GUILD_UPDATE']); + super(api, processor, 'guilds', Guild.factory, ['GUILD_CREATE', 'GUILD_UPDATE']); } } \ No newline at end of file diff --git a/src/services/messages.ts b/src/services/messages.ts new file mode 100644 index 0000000..30ad324 --- /dev/null +++ b/src/services/messages.ts @@ -0,0 +1,26 @@ +import { Message } from "../types/message"; +import type { API } from "./api"; +import { ObjectCache } from "./cache"; +import type { Client } from "./client"; +import type { Processor } from "./processor"; + +export class Messages extends ObjectCache { + constructor (protected client: Client, api: API, processor: Processor) { + super(api, processor, 'message', (data) => Message.factory(data, client, api), ['MESSAGE_CREATE', 'MESSAGE_UPDATE']); + } + + async fetch(channel_id: string, message_id: string, resolveReference: boolean = true): Promise { + const req = this.api.get(`/channels/${channel_id}/messages/${message_id}`).then((data) => Message.factory(data, this.client, this.api, undefined, resolveReference)); + this.cache.set(message_id, req); + req.catch(() => this.cache.delete(message_id)); + return await req; + } + + async get(channel_id: string, message_id: string, resolveReference: boolean = true): Promise { + if (this.cache.has(message_id)) { + return await Promise.resolve(this.cache.get(message_id)!); + } else { + return this.fetch(channel_id, message_id, resolveReference); + } + } +} \ No newline at end of file diff --git a/src/services/processor.ts b/src/services/processor.ts index eca1a42..e876215 100644 --- a/src/services/processor.ts +++ b/src/services/processor.ts @@ -2,15 +2,17 @@ import type { Client } from "./client"; import type { Context } from "../types/context"; import { HandlerType, type EventPayload, type Payload } from "../types/common"; import { BaseHandler, type Handler } from "../types/hander"; +import type { API } from "./api"; export class Processor { private handlers = { [HandlerType.Events]: new Map(), - [HandlerType.Commands]: new Map(), + [HandlerType.ChatCommands]: new Map(), + [HandlerType.SlashCommands]: new Map(), [HandlerType.Interactions]: new Map() }; - constructor(private client: Client) {}; + constructor(private client: Client, private api: API) {}; public register(type: HandlerType, event: string | string[], handler: Handler) { // Normalize events into an array @@ -46,18 +48,18 @@ export class Processor { } }; - public execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): void - public execute(type: Omit, event: string, context: Context, args: any[]): void - public execute(type: HandlerType, event: string, context: Context | null, argsOrPayload: any[] | EventPayload) { + public async execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): Promise + public async execute(type: Omit, event: string, context: Context, args: any[]): Promise + public async execute(type: HandlerType, event: string, context: Context | null, argsOrPayload: any[] | EventPayload) { const handlers = this.handlers[type].get(event); if (handlers) { for (const handler of handlers) { if (handler instanceof BaseHandler) { - handler.execute(context, argsOrPayload); + await Promise.resolve(handler.execute(context, argsOrPayload)); } else { // TODO: Fix ts error - handler(context, argsOrPayload); + await Promise.resolve(handler(context, argsOrPayload)) } } } @@ -66,21 +68,28 @@ export class Processor { [HandlerType.Events] = { register: (event: string | string[], handler: Handler) => this.register(HandlerType.Events, event, handler), unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Events, event, handler), - execute: (event: string, context: Context | null, payload: EventPayload) => this.execute(HandlerType.Events, event, context, payload), + execute: async (event: string, context: Context | null, payload: EventPayload) => this.execute(HandlerType.Events, event, context, payload), has: (event: string) => this.handlers[HandlerType.Events].has(event) && this.handlers[HandlerType.Events].get(event)?.length! > 0 }; - [HandlerType.Commands] = { - register: (event: string | string[], handler: Handler) => this.register(HandlerType.Commands, event, handler), - unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.Commands, event, handler), - execute: (event: string, context: Context, args: any[]) => this.execute(HandlerType.Commands, event, context, args), - has: (event: string) => this.handlers[HandlerType.Commands].has(event) ?? this.handlers[HandlerType.Commands].get(event)?.length! > 0 + [HandlerType.ChatCommands] = { + 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 + }; + + [HandlerType.SlashCommands] = { + register: (event: string | string[], handler: Handler) => this.register(HandlerType.SlashCommands, event, handler), + unregister: (event: string | string[], handler?: Handler) => this.unregister(HandlerType.SlashCommands, event, handler), + execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.SlashCommands, event, context, args), + has: (event: string) => this.handlers[HandlerType.SlashCommands].has(event) ?? this.handlers[HandlerType.SlashCommands].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: (event: string, context: Context, args: any[]) => this.execute(HandlerType.Interactions, event, context, args), + execute: async (event: string, context: Context, args: any[]) => this.execute(HandlerType.Interactions, event, context, args), has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0 }; } diff --git a/src/types/common.ts b/src/types/common.ts index efc2c90..0563bf4 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -10,7 +10,8 @@ export type ClientConfig = { } export enum HandlerType { Events = 'events', - Commands = 'commands', + ChatCommands = 'chat_commands', + SlashCommands = 'slash_commands', Interactions = 'interactions' } diff --git a/src/types/context.ts b/src/types/context.ts index f928c7e..7c90c4a 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,7 +1,7 @@ import type { API } from "../services/api"; import type { Client } from "../services/client"; import { isNil } from "../utils"; -import { Message } from "./message"; +import { DiscordMessage, Message } from "./message"; /** * Represents the context of a message in a Discord channel. * Provides methods to interact with the message and the channel it was sent in. @@ -26,39 +26,27 @@ export class Context { channel_id: string; guild_id?: string; - constructor(private client: Client, private api: API, private _message: Message) { - if (isNil(_message.id)) { + constructor(private client: Client, private api: API, public message: Message) { + console.log('Context created: ', ); + if (isNil(message.id)) { throw new Error('Message ID is required to create a context'); } - if (isNil(_message.channel_id)) { + if (isNil(message.channel_id)) { throw new Error('Channel ID is required to create a context'); } this.client = client; - this.message_id = _message.id; - this.channel_id = _message.channel_id; - this.guild_id = _message.guild_id; + this.message_id = message.id; + this.channel_id = message.channel_id; + this.guild_id = message.guild_id; } // get author() { // return this.client.users.get(this.author_id); // }; - get message(): Message & { getReference: () => Promise } { - return Object.assign(this._message, { - getReference: async () => { - const reference = this._message.message_reference; - if (isNil(reference)) { - return null; - } - - return Message.fromAPIResponse(await this.api.get(`/channels/${reference.channel_id}/messages/${reference.message_id}`)); - } - }); - } - - fetchGuild() { + async fetchGuild() { if (isNil(this.guild_id)) { return Promise.resolve(null); } @@ -78,11 +66,7 @@ export class Context { } if (reference) { - message.message_reference = { - message_id: this.message_id, - channel_id: this.channel_id, - guild_id: this.guild_id - }; + message.setReference(this.message) } return this.client.sendMessage(this.channel_id, message); @@ -108,7 +92,7 @@ export class Context { * @param {string} [message_id] - The ID of the message to delete. If not provided, the message ID of this context will be used. * @returns {Promise} A promise that resolves when the message is deleted. */ - public delete = (message_id?: string) => { + public deleteMessage = (message_id: string) => { return this.api.delete(`/channels/${this.channel_id}/messages/${message_id ?? this.message_id}`); }; @@ -119,10 +103,11 @@ export class Context { * @param {Message | string} message - The updated message content. * @returns {Promise} The updated message. */ - public edit = async (message: Message | string) => { - if (typeof message === 'string') { - message = new Message().setContent(message); + public async editMessage (message: Message, content?: string) { + const clone = new Message(message); + if (content) { + clone.setContent(content); } - return Message.fromAPIResponse(await this.api.patch(`/channels/${this.channel_id}/messages/${this.message_id}`, message)); + return Message.hydrate(DiscordMessage.fromAPIResponse(await this.api.patch(`/channels/${clone.channel_id}/messages/${clone.id}`, clone)), this.client, this.api); }; } \ No newline at end of file diff --git a/src/types/guild.ts b/src/types/guild.ts index 2bac8c4..593a5f3 100644 --- a/src/types/guild.ts +++ b/src/types/guild.ts @@ -4,6 +4,47 @@ import { Member } from "./member"; import { Role } from "./role"; export class Guild { + id: string; + name: string; + description: string | null; + icon: string | null; + banner: string | null; + owner_id: string; + // emojis: Emoji[]; + // roles: Role[]; + + constructor(data: Guild) { + this.id = data.id; + this.name = data.name; + this.description = data.description; + this.icon = data.icon; + this.banner = data.banner; + this.owner_id = data.owner_id; + // this.emojis = data.emojis; + // this.roles = data.roles; + } + + + public static async factory(data: any) { + return await Guild.hydrate(DiscordGuild.fromAPIResponse(data)); + } + + + public static async hydrate(data: DiscordGuild) { + return new Guild({ + id: data.id, + name: data.name, + description: data.description, + icon: data.icon, + banner: data.banner, + owner_id: data.owner_id, + // emojis: data.emojis, + // roles: data.roles, + }); + } +} + +export class DiscordGuild { system_channel_id: string | null; premium_progress_bar_enabled: boolean; region: string; @@ -124,7 +165,7 @@ export class Guild { this.explicit_content_filter = data.explicit_content_filter; } - static fromAPIResponse(data: any): Guild { - return new Guild(data); + static fromAPIResponse(data: any): DiscordGuild { + return new DiscordGuild(data); } } \ No newline at end of file diff --git a/src/types/hander.ts b/src/types/hander.ts index 4fb2cf0..a0ca79c 100644 --- a/src/types/hander.ts +++ b/src/types/hander.ts @@ -4,11 +4,11 @@ import type { Context } from "./context"; export class BaseHandler { constructor() {}; - public execute(context: Context | null, args: any[]) {}; + public async execute(context: Context | null, args: any[]) {}; } -export type CommandHandlerFunction = (context: Context, args: any[]) => void; -export type EventHandlerFunction = (context: Context | null, payload: EventPayload) => void; +export type CommandHandlerFunction = (context: Context, args: any[]) => Promise | void; +export type EventHandlerFunction = (context: Context | null, payload: EventPayload) => Promise | void; export type HandlerFunction = CommandHandlerFunction | EventHandlerFunction; export type Handler = CommandHandlerFunction | EventHandlerFunction | BaseHandler; diff --git a/src/types/message.ts b/src/types/message.ts index 2240757..28641c9 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -1,6 +1,8 @@ +import type { API } from '../services/api'; +import type { Client } from '../services/client'; import { isNil } from '../utils'; import { Embed } from './embed'; -import { User } from './user'; +import { DiscordUser, User } from './user'; export type Component = Partial