init for rewrite

This commit is contained in:
Jaron
2024-11-10 01:55:07 -06:00
parent 57a787e7ec
commit 56e10f043a
41 changed files with 1329 additions and 1673 deletions
+52
View File
@@ -0,0 +1,52 @@
import type { Client } from "./client";
export class API {
private readonly base_url: string = 'https://discord.com/api/v10';
constructor(private client: Client) {}
async request<T = any>(method: string, path: string, data?: any): Promise<T> {
const url = `${this.base_url}${path}`;
const headers = {
Authorization: `Bot ${this.client.token}`,
'Accept': 'application/json',
'Content-Type': 'application/json'
};
console.log(`API Request: ${method} ${url}`);
const response = await fetch(url, {
method,
headers,
body: JSON.stringify(data),
});
if (!response.ok) {
console.error(`Failed to fetch: ${response.statusText} ${response.url} ${response.status}`);
console.error(response.json());
throw new Error(`Failed to fetch: ${response.statusText}`);
}
const body = await response.json();
console.log('API Response:', JSON.stringify(body, null, 4));
return body;
}
async get<T = any>(path: string): Promise<T> {
return this.request('GET', path);
}
async post<T = any>(path: string, data: any): Promise<T> {
return this.request('POST', path, data);
}
async patch<T = any>(path: string, data: any): Promise<T> {
return this.request('PATCH', path, data);
}
async delete<T = any>(path: string): Promise<T> {
return this.request('DELETE', path);
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { Context } from "../types/context";
import { exists } from "../utils";
import type { API } from "./api";
import type { Processor } from "./processor";
export class ObjectCache<T> {
cache = new Map<string, T>();
private readonly type: string;
constructor(private api: API, processor: Processor, type: string, events: string[]) {
this.type = type;
processor.events.register(events, (_context: Context, e: Record<string, any>) => {
this.cache.set(e.id, e as T);
});
}
set(object_id: string, object: T): void {
this.cache.set(object_id, object);
}
// update(object_id: string, object: Partial<T>): void {
// const existing = this.cache.get(object_id);
// if (exists(existing)) {
// // TODO: Idk wtf happens if this is called on two classes
// this.cache.set(object_id, { ...existing, ...object });
// } else {
// this.cache.set(object_id, object as T);
// }
// }
async fetch(object_id: string): Promise<T> {
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;
});
}
get(object_id: string): T | Promise<T> {
if (this.cache.has(object_id)) {
return Promise.resolve(this.cache.get(object_id)!);
} else {
return this.fetch(object_id);
}
}
list(): Map<string, T> {
return this.cache;
}
}
+92
View File
@@ -0,0 +1,92 @@
import { API } from './api';
import { Gateway } from './gateway';
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 { Guilds } from './guilds';
import { Users } from './users';
export class Client {
readonly token: string;
readonly application_id: string;
readonly intents: Intent[] = [Intent.DEFAULT];
prefix: string;
// Internal services
protected _api: API;
protected _processor: Processor;
protected _gateway: Gateway;
guilds: Guilds;
users: Users;
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 ?? '!';
// Initialize internal services
this._api = new API(this);
this._processor = new Processor(this);
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;
}
private static validateConfig(config: ClientConfig) {
if (isNil(config.token)) {
throw new Error('Token is required');
}
if (isNil(config.application_id)) {
throw new Error('Application ID is required');
}
if (exists(config.prefix) && typeof config.prefix !== 'string') {
throw new Error('Prefix must be a string');
}
if (exists(config.intents)) {
if (!Array.isArray(config.intents)) {
throw new Error('Intents must be an array');
}
for (const intent of config.intents) {
if (!Intent[intent]) {
throw new Error(`Invalid intent: ${intent}`);
}
}
}
}
sendMessage = async (channel_id: string, message: Message | string) => {
if (typeof message === 'string') {
message = new Message().setContent(message);
}
return Message.fromAPIResponse(await this.api.post(`/channels/${channel_id}/messages`, message.toJSON()));
}
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)
}
connect = () => this.gateway.connect();
}
+216
View File
@@ -0,0 +1,216 @@
import { WebSocket, type Data } from 'ws';
import type { Client } from "./client";
import 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 { Message } from '../types/message';
import { User } from '../types/user';
export class Gateway {
private socket: WebSocket | null = null;
private heartbeat_interval: number | null = null;
private heartbeat_timeout: Timer | null = null;
private sequence: number | null = null;
// Internal state
private user: any;
constructor(private client: Client, private processor: Processor, private api: API) {}
private onOpen = () => {
log('Connected to gateway');
}
private onMessage = (packet: Data) => {
// console.log(packet);
// Allocate a variable to store the parsed payload
let data: Payload | null = null;
try {
// Attempt to parse the packet data
data = JSON.parse(packet.toString());
} catch (e) {
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 the data is successfully parsed check the opcode
const code = data.op;
if (code === 0) {
// Event received
this.parseEvent(data);
} else if (code === 1) {
// Heartbeat requested
this.send({ op: 1, d: this.sequence });
} else if (code === 9) {
// Reconnect requested
} else if (code === 10) {
// Hello
if (!isObject(data.d)) {
throw new Error('Invalid HELLO payload received');
}
// Store the heartbeat interval
this.heartbeat_interval = data.d?.heartbeat_interval;
// Kick off the heartbeat loop
this.heartbeat();
setTimeout(this.indentify, 2500);
} else if (code === 11) {
// Heartbeat ACK
log('Heartbeat ACK');
}
}
}
private heartbeat() {
log('Sending heartbeat');
if (exists(this.heartbeat_interval)) {
// If a previous heartbeat timeout exists, clear it
if (exists(this.heartbeat_timeout)) {
clearTimeout(this.heartbeat_timeout);
}
// Set a timeout to ensure the heartbeat is sent within the interval
this.heartbeat_timeout = setTimeout(() => {
this.send({ op: 1, d: this.sequence });
this.heartbeat();
}, this.heartbeat_interval * Math.random());
} else {
throw new Error('Heartbeat interval not set');
}
}
protected send = (payload: Partial<Payload>) => {
if (isNil(this.socket)) {
throw new Error('Socket is not connected');
}
log('='.repeat(25));
log('sending: ', JSON.stringify(payload, null, 4));
log('='.repeat(25));
this.socket.send(JSON.stringify(payload));
};
private indentify = () => {
this.send({
op: 2,
d: {
token: this.client.token,
intents: this.client.intents.reduce((acc, intent) => acc | intent, 0),
properties: {
os: process.platform,
browser: 'discord.ts',
device: 'discord.ts'
}
}
});
};
private parseEvent(payload: Payload) {
if (isNil(payload.t)) {
throw new Error('Event name is required');
}
log('Event:', payload.t);
const event_name = payload.t;
const event_data = payload.d;
if (!isObject(event_data)) {
throw new Error('Invalid event data received');
}
if (event_name === 'READY') {
this.user = event_data.user;
log('User:', this.user);
} else if (event_name === 'INTERACTION_CREATE') {
// this.handleInteractionCreate(payload);
} else if (event_name === 'MESSAGE_CREATE') {
this.handleMessageCreate(payload as Payload);
} else if (event_name === 'MESSAGE_UPDATE') {
// this.handleMessageCreate(payload);
}
// Allocate a var to store the context
let context = null;
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);
// And then generate the context
context = new Context(this.client, this.api, message);
}
// Call event handlers
this.processor.events.execute(event_name, context, event_data);
}
private handleMessageCreate = async (payload: Payload) => {
if (!isObject(payload.d)) {
throw new Error('Invalid MESSAGE_CREATE payload received');
}
// Ignore bots
if (payload.d.author.bot) {
return;
}
// Ignore self
if (payload.d.author.id === this.user.id) {
return;
}
// Extract the author
const author = User.fromAPIResponse(payload.d.author);
// Store the author in the cache
this.client.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}>`)) {
// Determine the prefix
const prefix = content.startsWith(this.client.prefix) ? this.client.prefix : `<@${this.user.id}> `;
// Parse command
const args = content
.slice(prefix.length)
.trim()
.split(/\s+(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)/g);
// Ignore empty commands
if (args.length === 0) {
return;
}
// Extract the command name
// TODO: Case insensitive config?
const command = args.shift()!;
// Create a new message object from the payload
const message = Message.fromAPIResponse(payload.d);
// Generate context
const context = new Context(this.client, this.api, message);
// Check if command exists
if (this.processor.commands.has(command)) {
// Execute the handler
this.processor.commands.execute(command, context, args);
} else {
// TODO: Custom unknown command handler
context.reply(`Unknown command: ${command}`);
}
}
};
async connect() {
// Fetch the Discord gateway URL
const response = await this.api.request('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');
// 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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import type { Guild } from "../types/guild";
import type { API } from "./api";
import { ObjectCache } from "./cache";
import type { Processor } from "./processor";
export class Guilds extends ObjectCache<Guild> {
constructor (api: API, processor: Processor) {
super(api, processor, 'guilds', ['GUILD_CREATE', 'GUILD_UPDATE']);
}
}
+86
View File
@@ -0,0 +1,86 @@
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";
export class Processor {
private handlers = {
[HandlerType.Events]: new Map<string, Handler[]>(),
[HandlerType.Commands]: new Map<string, Handler[]>(),
[HandlerType.Interactions]: new Map<string, Handler[]>()
};
constructor(private client: Client) {};
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) {
const handlers = this.handlers[type].get(e) || [];
handlers.push(handler);
this.handlers[type].set(e, handlers);
}
};
public unregister(type: HandlerType, event: string | string[], handler?: Handler) {
// Normalize events into an array
const events = Array.isArray(event) ? event : [event];
// Unregister the handler for each event
for (const e of events) {
if (handler) {
// Get the handlers for the event
const handlers = this.handlers[type].get(e);
if (handlers) {
// Find the index of the handler
const index = handlers.indexOf(handler);
if (index !== -1) {
// Remove the handler from the array
handlers.splice(index, 1);
}
}
} else {
// If no handler is provided, clear all handlers for the event
this.handlers[type].clear();
}
}
};
public execute(type: HandlerType.Events, event: string, context: Context | null, payload: EventPayload): void
public execute(type: Omit<HandlerType, HandlerType.Events>, event: string, context: Context, args: any[]): void
public 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);
} else {
// TODO: Fix ts error
handler(context, argsOrPayload);
}
}
}
}
[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),
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.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),
has: (event: string) => this.handlers[HandlerType.Interactions].has(event) ?? this.handlers[HandlerType.Interactions].get(event)?.length! > 0
};
}
+10
View File
@@ -0,0 +1,10 @@
import type { User } from "../types/user";
import type { API } from "./api";
import { ObjectCache } from "./cache";
import type { Processor } from "./processor";
export class Users extends ObjectCache<User> {
constructor (api: API, processor: Processor) {
super(api, processor, 'users', []);
}
}