From 935456906d84cd07a67e44cb22ee0e8803fb191c Mon Sep 17 00:00:00 2001 From: DjDeveloperr <=> Date: Sat, 31 Oct 2020 17:15:33 +0530 Subject: [PATCH] RESTManager, CacheAdapters, and improvements --- src/gateway/handlers/channelCreate.ts | 1 + src/gateway/handlers/channelDelete.ts | 5 +- src/gateway/handlers/channelPinsUpdate.ts | 5 +- src/gateway/handlers/channelUpdate.ts | 4 +- src/gateway/handlers/guildCreate.ts | 11 +- src/gateway/handlers/guildDelete.ts | 5 +- src/gateway/handlers/guildUpdate.ts | 10 +- src/gateway/handlers/ready.ts | 8 +- src/gateway/index.ts | 25 +- src/managers/BaseManager.ts | 32 ++ src/managers/ChannelsManager.ts | 26 ++ src/managers/EmojisManager.ts | 20 ++ src/managers/GuildsManager.ts | 20 ++ src/managers/RolesManager.ts | 25 ++ src/managers/UsersManager.ts | 20 ++ src/models/CacheAdapter.ts | 48 +++ src/models/client.ts | 48 ++- src/models/rest.ts | 369 +++++++++++++++++++++- src/structures/guild.ts | 23 +- src/structures/user.ts | 8 + src/test/config.ts.sample | 3 +- src/test/index.ts | 4 +- src/types/gatewayBot.ts | 11 + src/utils/collection.ts | 87 +++++ src/utils/delay.ts | 3 + src/utils/index.ts | 6 +- 26 files changed, 770 insertions(+), 57 deletions(-) create mode 100644 src/managers/BaseManager.ts create mode 100644 src/managers/ChannelsManager.ts create mode 100644 src/managers/EmojisManager.ts create mode 100644 src/managers/GuildsManager.ts create mode 100644 src/managers/RolesManager.ts create mode 100644 src/managers/UsersManager.ts create mode 100644 src/models/CacheAdapter.ts create mode 100644 src/types/gatewayBot.ts create mode 100644 src/utils/collection.ts create mode 100644 src/utils/delay.ts diff --git a/src/gateway/handlers/channelCreate.ts b/src/gateway/handlers/channelCreate.ts index 4283362..1b35974 100644 --- a/src/gateway/handlers/channelCreate.ts +++ b/src/gateway/handlers/channelCreate.ts @@ -8,6 +8,7 @@ export const channelCreate: GatewayEventHandler = ( const channel = getChannelByType(gateway.client, d) if (channel !== undefined) { + gateway.client.channels.set(d.id, d) gateway.client.emit('channelCreate', channel) } } diff --git a/src/gateway/handlers/channelDelete.ts b/src/gateway/handlers/channelDelete.ts index 1b06fbf..0db077d 100644 --- a/src/gateway/handlers/channelDelete.ts +++ b/src/gateway/handlers/channelDelete.ts @@ -1,14 +1,13 @@ import { Gateway, GatewayEventHandler } from '../index.ts' -import cache from '../../models/cache.ts' import { Channel } from '../../structures/channel.ts' export const channelDelete: GatewayEventHandler = ( gateway: Gateway, d: any ) => { - const channel: Channel = cache.get('channel', d.id) + const channel: Channel = gateway.client.channels.get(d.id) if (channel !== undefined) { - cache.del('channel', d.id) + gateway.client.channels.delete(d.id) gateway.client.emit('channelDelete', channel) } } diff --git a/src/gateway/handlers/channelPinsUpdate.ts b/src/gateway/handlers/channelPinsUpdate.ts index 40a0df3..efbe2d2 100644 --- a/src/gateway/handlers/channelPinsUpdate.ts +++ b/src/gateway/handlers/channelPinsUpdate.ts @@ -1,16 +1,19 @@ import { Gateway, GatewayEventHandler } from '../index.ts' import cache from '../../models/cache.ts' import { TextChannel } from '../../structures/textChannel.ts' +import { ChannelPayload } from "../../types/channelTypes.ts" export const channelPinsUpdate: GatewayEventHandler = ( gateway: Gateway, d: any ) => { - const after: TextChannel = cache.get('textchannel', d.channel_id) + const after: TextChannel = gateway.client.channels.get(d.channel_id) if (after !== undefined) { const before = after.refreshFromData({ last_pin_timestamp: d.last_pin_timestamp }) + let raw = gateway.client.channels._get(d.channel_id) as ChannelPayload; + gateway.client.channels.set(after.id, Object.assign(raw, { last_pin_timestamp: d.last_pin_timestamp })) gateway.client.emit('channelPinsUpdate', before, after) } } diff --git a/src/gateway/handlers/channelUpdate.ts b/src/gateway/handlers/channelUpdate.ts index 8ce0d90..6d70b50 100644 --- a/src/gateway/handlers/channelUpdate.ts +++ b/src/gateway/handlers/channelUpdate.ts @@ -1,4 +1,3 @@ -import cache from '../../models/cache.ts' import { Channel } from '../../structures/channel.ts' import getChannelByType from '../../utils/getChannelByType.ts' import { Gateway, GatewayEventHandler } from '../index.ts' @@ -7,9 +6,10 @@ export const channelUpdate: GatewayEventHandler = ( gateway: Gateway, d: any ) => { - const oldChannel: Channel = cache.get('channel', d.id) + const oldChannel: Channel = gateway.client.channels.get(d.id) if (oldChannel !== undefined) { + gateway.client.channels.set(d.id, d) if (oldChannel.type !== d.type) { const channel: Channel = getChannelByType(gateway.client, d) ?? oldChannel gateway.client.emit('channelUpdate', oldChannel, channel) diff --git a/src/gateway/handlers/guildCreate.ts b/src/gateway/handlers/guildCreate.ts index e1bde3d..48db410 100644 --- a/src/gateway/handlers/guildCreate.ts +++ b/src/gateway/handlers/guildCreate.ts @@ -1,14 +1,15 @@ import { Gateway, GatewayEventHandler } from '../index.ts' -import cache from '../../models/cache.ts' import { Guild } from '../../structures/guild.ts' export const guildCreate: GatewayEventHandler = (gateway: Gateway, d: any) => { - let guild: Guild = cache.get('guild', d.id) + let guild: Guild | void = gateway.client.guilds.get(d.id) if (guild !== undefined) { + // It was just lazy load, so we don't fire the event as its gonna fire for every guild bot is in + gateway.client.guilds.set(d.id, d) guild.refreshFromData(d) } else { - guild = new Guild(gateway.client, d) + gateway.client.guilds.set(d.id, d) + guild = gateway.client.guilds.get(d.id) + gateway.client.emit('guildCreate', guild) } - - gateway.client.emit('guildCreate', guild) } diff --git a/src/gateway/handlers/guildDelete.ts b/src/gateway/handlers/guildDelete.ts index abaa748..1849f60 100644 --- a/src/gateway/handlers/guildDelete.ts +++ b/src/gateway/handlers/guildDelete.ts @@ -1,13 +1,12 @@ -import cache from '../../models/cache.ts' import { Guild } from '../../structures/guild.ts' import { Gateway, GatewayEventHandler } from '../index.ts' export const guildDelte: GatewayEventHandler = (gateway: Gateway, d: any) => { - const guild: Guild = cache.get('guild', d.id) + const guild: Guild | void = gateway.client.guilds.get(d.id) if (guild !== undefined) { guild.refreshFromData(d) - cache.del('guild', d.id) + gateway.client.guilds.delete(d.id) gateway.client.emit('guildDelete', guild) } } diff --git a/src/gateway/handlers/guildUpdate.ts b/src/gateway/handlers/guildUpdate.ts index 1a1847f..7acf31a 100644 --- a/src/gateway/handlers/guildUpdate.ts +++ b/src/gateway/handlers/guildUpdate.ts @@ -3,9 +3,9 @@ import cache from '../../models/cache.ts' import { Guild } from '../../structures/guild.ts' export const guildUpdate: GatewayEventHandler = (gateway: Gateway, d: any) => { - const after: Guild = cache.get('guild', d.id) - if (after !== undefined) { - const before: Guild = after.refreshFromData(d) - gateway.client.emit('guildUpdate', before, after) - } + const before: Guild | void = gateway.client.guilds.get(d.id) + if(!before) return + gateway.client.guilds.set(d.id, d) + const after: Guild | void = gateway.client.guilds.get(d.id) + gateway.client.emit('guildUpdate', before, after) } diff --git a/src/gateway/handlers/ready.ts b/src/gateway/handlers/ready.ts index ba44d58..7a04e44 100644 --- a/src/gateway/handlers/ready.ts +++ b/src/gateway/handlers/ready.ts @@ -1,4 +1,3 @@ -import { Guild } from '../../structures/guild.ts' import { User } from '../../structures/user.ts' import { GuildPayload } from '../../types/guildTypes.ts' import { Gateway, GatewayEventHandler } from '../index.ts' @@ -6,6 +5,9 @@ import { Gateway, GatewayEventHandler } from '../index.ts' export const ready: GatewayEventHandler = (gateway: Gateway, d: any) => { gateway.client.user = new User(gateway.client, d.user) gateway.sessionID = d.session_id - d.guilds.forEach((guild: GuildPayload) => new Guild(gateway.client, guild)) + gateway.debug(`Received READY. Session: ${gateway.sessionID}`) + d.guilds.forEach((guild: GuildPayload) => { + gateway.client.guilds.set(guild.id, guild) + }) gateway.client.emit('ready') -} +} \ No newline at end of file diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 809828a..a0aa1b2 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -7,6 +7,8 @@ import { import { GatewayResponse } from '../types/gatewayResponse.ts' import { GatewayOpcodes, GatewayIntents } from '../types/gatewayTypes.ts' import { gatewayHandlers } from './handlers/index.ts' +import { GATEWAY_BOT } from '../types/endpoint.ts' +import { GatewayBotPayload } from "../types/gatewayBot.ts" /** * Handles Discord gateway connection. @@ -24,7 +26,7 @@ class Gateway { heartbeatIntervalID?: number sequenceID?: number sessionID?: string - lastPingTimestemp = 0 + lastPingTimestamp = 0 private heartbeatServerResponded = false client: Client @@ -46,6 +48,7 @@ class Gateway { private onopen (): void { this.connected = true + this.debug("Connected to Gateway!") } private onmessage (event: MessageEvent): void { @@ -63,6 +66,7 @@ class Gateway { switch (op) { case GatewayOpcodes.HELLO: this.heartbeatInterval = d.heartbeat_interval + this.debug(`Received HELLO. Heartbeat Interval: ${this.heartbeatInterval}`) this.heartbeatIntervalID = setInterval(() => { if (this.heartbeatServerResponded) { this.heartbeatServerResponded = false @@ -79,7 +83,7 @@ class Gateway { d: this.sequenceID ?? null }) ) - this.lastPingTimestemp = Date.now() + this.lastPingTimestamp = Date.now() }, this.heartbeatInterval) if (!this.initialized) { @@ -92,7 +96,8 @@ class Gateway { case GatewayOpcodes.HEARTBEAT_ACK: this.heartbeatServerResponded = true - this.client.ping = Date.now() - this.lastPingTimestemp + this.client.ping = Date.now() - this.lastPingTimestamp + this.debug(`Received Heartbeat Ack. Ping Recognized: ${this.client.ping}ms`) break case GatewayOpcodes.INVALID_SESSION: @@ -135,7 +140,14 @@ class Gateway { console.log(eventError) } - private sendIdentify (): void { + private async sendIdentify () { + this.debug("Fetching /gateway/bot...") + let info = await this.client.rest.get(GATEWAY_BOT()) as GatewayBotPayload + if(info.session_start_limit.remaining == 0) throw new Error("Session Limit Reached. Retry After " + info.session_start_limit.reset_after + "ms") + this.debug("Recommended Shards: " + info.shards) + this.debug("=== Session Limit Info ===") + this.debug(`Remaining: ${info.session_start_limit.remaining}/${info.session_start_limit.total}`) + this.debug(`Reset After: ${info.session_start_limit.reset_after}ms`) this.websocket.send( JSON.stringify({ op: GatewayOpcodes.IDENTIFY, @@ -164,6 +176,7 @@ class Gateway { } private sendResume (): void { + this.debug(`Preparing to resume with Session: ${this.sessionID}`) this.websocket.send( JSON.stringify({ op: GatewayOpcodes.RESUME, @@ -176,6 +189,10 @@ class Gateway { ) } + debug(msg: string) { + this.client.debug("Gateway", msg) + } + initWebsocket (): void { this.websocket = new WebSocket( // eslint-disable-next-line @typescript-eslint/restrict-template-expressions diff --git a/src/managers/BaseManager.ts b/src/managers/BaseManager.ts new file mode 100644 index 0000000..30b8e03 --- /dev/null +++ b/src/managers/BaseManager.ts @@ -0,0 +1,32 @@ +import { Client } from "../models/client.ts"; +import { Base } from "../structures/base.ts"; + +export class BaseManager { + client: Client + cacheName: string + dataType: typeof Base + + constructor(client: Client, cacheName: string, dataType: typeof Base) { + this.client = client + this.cacheName = cacheName + this.dataType = dataType + } + + _get(key: string): T { + return this.client.cache.get(this.cacheName, key) as T + } + + get(key: string): T2 | void { + let raw = this._get(key) + if(!raw) return + return new this.dataType(this.client, raw) as any + } + + set(key: string, value: T) { + return this.client.cache.set(this.cacheName, key, value) + } + + delete(key: string) { + return this.client.cache.delete(this.cacheName, key) + } +} \ No newline at end of file diff --git a/src/managers/ChannelsManager.ts b/src/managers/ChannelsManager.ts new file mode 100644 index 0000000..09af63e --- /dev/null +++ b/src/managers/ChannelsManager.ts @@ -0,0 +1,26 @@ +import { Client } from "../models/client.ts"; +import { Channel } from "../structures/channel.ts"; +import { User } from "../structures/user.ts"; +import { ChannelPayload } from "../types/channelTypes.ts"; +import { CHANNEL } from "../types/endpoint.ts"; +import { BaseManager } from "./BaseManager.ts"; + +export class ChannelsManager extends BaseManager { + constructor(client: Client) { + super(client, "channels", User) + } + + // Override get method as Generic + get(key: string): T { + return new this.dataType(this.client, this._get(key)) as any + } + + fetch(id: string) { + return new Promise((res, rej) => { + this.client.rest.get(CHANNEL(id)).then(data => { + this.set(id, data as ChannelPayload) + res(new Channel(this.client, data as ChannelPayload)) + }).catch(e => rej(e)) + }) + } +} \ No newline at end of file diff --git a/src/managers/EmojisManager.ts b/src/managers/EmojisManager.ts new file mode 100644 index 0000000..f1d55d6 --- /dev/null +++ b/src/managers/EmojisManager.ts @@ -0,0 +1,20 @@ +import { Client } from "../models/client.ts"; +import { Emoji } from "../structures/emoji.ts"; +import { EmojiPayload } from "../types/emojiTypes.ts"; +import { CHANNEL } from "../types/endpoint.ts"; +import { BaseManager } from "./BaseManager.ts"; + +export class EmojisManager extends BaseManager { + constructor(client: Client) { + super(client, "emojis", Emoji) + } + + fetch(id: string) { + return new Promise((res, rej) => { + this.client.rest.get(CHANNEL(id)).then(data => { + this.set(id, data as EmojiPayload) + res(new Emoji(this.client, data as EmojiPayload)) + }).catch(e => rej(e)) + }) + } +} \ No newline at end of file diff --git a/src/managers/GuildsManager.ts b/src/managers/GuildsManager.ts new file mode 100644 index 0000000..56d7f80 --- /dev/null +++ b/src/managers/GuildsManager.ts @@ -0,0 +1,20 @@ +import { Client } from "../models/client.ts"; +import { Guild } from "../structures/guild.ts"; +import { GUILD } from "../types/endpoint.ts"; +import { GuildPayload } from "../types/guildTypes.ts"; +import { BaseManager } from "./BaseManager.ts"; + +export class GuildManager extends BaseManager { + constructor(client: Client) { + super(client, "guilds", Guild) + } + + fetch(id: string) { + return new Promise((res, rej) => { + this.client.rest.get(GUILD(id)).then(data => { + this.set(id, data as GuildPayload) + res(new Guild(this.client, data as GuildPayload)) + }).catch(e => rej(e)) + }) + } +} \ No newline at end of file diff --git a/src/managers/RolesManager.ts b/src/managers/RolesManager.ts new file mode 100644 index 0000000..e2d75b9 --- /dev/null +++ b/src/managers/RolesManager.ts @@ -0,0 +1,25 @@ +import { Client } from "../models/client.ts"; +import { Guild } from "../structures/guild.ts"; +import { Role } from "../structures/role.ts"; +import { User } from "../structures/user.ts"; +import { GUILD_ROLE } from "../types/endpoint.ts"; +import { RolePayload } from "../types/roleTypes.ts"; +import { BaseManager } from "./BaseManager.ts"; + +export class RolesManager extends BaseManager { + guild: Guild + + constructor(client: Client, guild: Guild) { + super(client, "roles:" + guild.id, Role) + this.guild = guild + } + + fetch(id: string) { + return new Promise((res, rej) => { + this.client.rest.get(GUILD_ROLE(this.guild.id, id)).then(data => { + this.set(id, data as RolePayload) + res(new Role(this.client, data as RolePayload)) + }).catch(e => rej(e)) + }) + } +} \ No newline at end of file diff --git a/src/managers/UsersManager.ts b/src/managers/UsersManager.ts new file mode 100644 index 0000000..0920034 --- /dev/null +++ b/src/managers/UsersManager.ts @@ -0,0 +1,20 @@ +import { Client } from "../models/client.ts"; +import { User } from "../structures/user.ts"; +import { USER } from "../types/endpoint.ts"; +import { UserPayload } from "../types/userTypes.ts"; +import { BaseManager } from "./BaseManager.ts"; + +export class UserManager extends BaseManager { + constructor(client: Client) { + super(client, "users", User) + } + + fetch(id: string) { + return new Promise((res, rej) => { + this.client.rest.get(USER(id)).then(data => { + this.set(id, data as UserPayload) + res(new User(this.client, data as UserPayload)) + }).catch(e => rej(e)) + }) + } +} \ No newline at end of file diff --git a/src/models/CacheAdapter.ts b/src/models/CacheAdapter.ts new file mode 100644 index 0000000..8768438 --- /dev/null +++ b/src/models/CacheAdapter.ts @@ -0,0 +1,48 @@ +import { Collection } from "../utils/collection.ts"; +import { Client } from "./client.ts"; + +export interface ICacheAdapter { + client: Client + get: (cacheName: string, key: string) => any + set: (cacheName: string, key: string, value: any) => any + delete: (cacheName: string, key: string) => boolean + array: (cacheName: string) => void | any[] +} + +export class DefaultCacheAdapter implements ICacheAdapter { + client: Client + data: { + [name: string]: Collection + } = {} + + constructor(client: Client) { + this.client = client + } + + get(cacheName: string, key: string) { + let cache = this.data[cacheName] + if (!cache) return; + return cache.get(key) + } + + set(cacheName: string, key: string, value: any) { + let cache = this.data[cacheName] + if (!cache) { + this.data[cacheName] = new Collection() + cache = this.data[cacheName] + } + cache.set(key, value) + } + + delete(cacheName: string, key: string) { + let cache = this.data[cacheName] + if (!cache) return false + return cache.delete(key) + } + + array(cacheName: string) { + let cache = this.data[cacheName] + if (!cache) return + return cache.array() + } +} \ No newline at end of file diff --git a/src/models/client.ts b/src/models/client.ts index 5760c37..4cf1444 100644 --- a/src/models/client.ts +++ b/src/models/client.ts @@ -1,30 +1,64 @@ import { User } from '../structures/user.ts' import { GatewayIntents } from '../types/gatewayTypes.ts' import { Gateway } from '../gateway/index.ts' -import { Rest } from './rest.ts' +import { RESTManager } from './rest.ts' import EventEmitter from 'https://deno.land/std@0.74.0/node/events.ts' +import { DefaultCacheAdapter, ICacheAdapter } from "./CacheAdapter.ts" +import { UserManager } from "../managers/UsersManager.ts" +import { GuildManager } from "../managers/GuildsManager.ts" +import { EmojisManager } from "../managers/EmojisManager.ts" +import { ChannelsManager } from "../managers/ChannelsManager.ts" + +/** Some Client Options to modify behaviour */ +export interface ClientOptions { + token?: string + intents?: GatewayIntents[] + cache?: ICacheAdapter +} /** * Discord Client. */ export class Client extends EventEmitter { gateway?: Gateway - rest?: Rest + rest: RESTManager = new RESTManager(this) user?: User ping = 0 token?: string + cache: ICacheAdapter = new DefaultCacheAdapter(this) + intents?: GatewayIntents[] + users: UserManager = new UserManager(this) + guilds: GuildManager = new GuildManager(this) + channels: ChannelsManager = new ChannelsManager(this) + emojis: EmojisManager = new EmojisManager(this) - // constructor () { - // super() - // } + constructor (options: ClientOptions = {}) { + super() + this.token = options.token + this.intents = options.intents + if(options.cache) this.cache = options.cache + } + + debug(tag: string, msg: string) { + this.emit("debug", `[${tag}] ${msg}`) + } /** * This function is used for connect to discord. * @param token Your token. This is required. * @param intents Gateway intents in array. This is required. */ - connect (token: string, intents: GatewayIntents[]): void { - this.token = token + connect (token?: string, intents?: GatewayIntents[]): void { + if(!token && this.token) token = this.token + else if(!this.token && token) { + this.token = token + } + else throw new Error("No Token Provided") + if(!intents && this.intents) intents = this.intents + else if(intents && !this.intents) { + this.intents = intents + } + else throw new Error("No Gateway Intents were provided") this.gateway = new Gateway(this, token, intents) } } diff --git a/src/models/rest.ts b/src/models/rest.ts index 2c38e62..5099664 100644 --- a/src/models/rest.ts +++ b/src/models/rest.ts @@ -1,11 +1,364 @@ -import { Client } from './client.ts' +import { delay } from "../utils/index.ts"; +import * as baseEndpoints from "../consts/urlsAndVersions.ts"; +import { Client } from "./client.ts"; -class Rest { - client: Client - constructor (client: Client) { - this.client = client - } - // TODO: make endpoints function +export enum HttpResponseCode { + Ok = 200, + Created = 201, + NoContent = 204, + NotModified = 304, + BadRequest = 400, + Unauthorized = 401, + Forbidden = 403, + NotFound = 404, + MethodNotAllowed = 405, + TooManyRequests = 429, + GatewayUnavailable = 502, + // ServerError left untyped because it's 5xx. } -export { Rest } +export type RequestMethods = + | "get" + | "post" + | "put" + | "patch" + | "head" + | "delete"; + +export interface QueuedRequest { + callback: () => Promise< + void | { + rateLimited: any; + beforeFetch: boolean; + bucketID?: string | null; + } + >; + bucketID?: string | null; + url: string; +} + +export interface RateLimitedPath { + url: string; + resetTimestamp: number; + bucketID: string | null; +} + +export class RESTManager { + client: Client; + globallyRateLimited: boolean = false; + queueInProcess: boolean = false; + pathQueues: { [key: string]: QueuedRequest[] } = {}; + ratelimitedPaths = new Map(); + + constructor(client: Client) { + this.client = client; + } + + async processRateLimitedPaths() { + const now = Date.now(); + this.ratelimitedPaths.forEach((value, key) => { + if (value.resetTimestamp > now) return; + this.ratelimitedPaths.delete(key); + if (key === "global") this.globallyRateLimited = false; + }); + + await delay(1000); + this.processRateLimitedPaths(); + } + + addToQueue(request: QueuedRequest) { + const route = request.url.substring(baseEndpoints.DISCORD_API_URL.length + 1); + const parts = route.split("/"); + // Remove the major param + parts.shift(); + const [id] = parts; + + if (this.pathQueues[id]) { + this.pathQueues[id].push(request); + } else { + this.pathQueues[id] = [request]; + } + } + + async cleanupQueues() { + Object.entries(this.pathQueues).map(([key, value]) => { + if (!value.length) { + // Remove it entirely + delete this.pathQueues[key]; + } + }); + } + + async processQueue() { + if ( + (Object.keys(this.pathQueues).length) && !this.globallyRateLimited + ) { + await Promise.allSettled( + Object.values(this.pathQueues).map(async (pathQueue) => { + const request = pathQueue.shift(); + if (!request) return; + + const rateLimitedURLResetIn = await this.checkRatelimits(request.url); + + if (request.bucketID) { + const rateLimitResetIn = await this.checkRatelimits(request.bucketID); + if (rateLimitResetIn) { + // This request is still rate limited readd to queue + this.addToQueue(request); + } else if (rateLimitedURLResetIn) { + // This URL is rate limited readd to queue + this.addToQueue(request); + } else { + // This request is not rate limited so it should be run + const result = await request.callback(); + if (result && result.rateLimited) { + this.addToQueue( + { ...request, bucketID: result.bucketID || request.bucketID }, + ); + } + } + } else { + if (rateLimitedURLResetIn) { + // This URL is rate limited readd to queue + this.addToQueue(request); + } else { + // This request has no bucket id so it should be processed + const result = await request.callback(); + if (request && result && result.rateLimited) { + this.addToQueue( + { ...request, bucketID: result.bucketID || request.bucketID }, + ); + } + } + } + }), + ); + } + + if (Object.keys(this.pathQueues).length) { + await delay(1000); + this.processQueue(); + this.cleanupQueues(); + } else this.queueInProcess = false; + } + + createRequestBody(body: any, method: RequestMethods) { + const headers: { [key: string]: string } = { + Authorization: `Bot ${this.client.token}`, + "User-Agent": + `DiscordBot (discord.deno)`, + }; + + if(!this.client.token) delete headers["Authorization"]; + + if (method === "get") body = undefined; + + if (body?.reason) { + headers["X-Audit-Log-Reason"] = encodeURIComponent(body.reason); + } + + if (body?.file) { + const form = new FormData(); + form.append("file", body.file.blob, body.file.name); + form.append("payload_json", JSON.stringify({ ...body, file: undefined })); + body.file = form; + } else if ( + body && !["get", "delete"].includes(method) + ) { + headers["Content-Type"] = "application/json"; + } + + return { + headers, + body: body?.file || JSON.stringify(body), + method: method.toUpperCase(), + }; + } + + async checkRatelimits(url: string) { + const ratelimited = this.ratelimitedPaths.get(url); + const global = this.ratelimitedPaths.get("global"); + const now = Date.now(); + + if (ratelimited && now < ratelimited.resetTimestamp) { + return ratelimited.resetTimestamp - now; + } + if (global && now < global.resetTimestamp) { + return global.resetTimestamp - now; + } + + return false; + } + + async runMethod( + method: RequestMethods, + url: string, + body?: unknown, + retryCount = 0, + bucketID?: string | null, + ) { + + const errorStack = new Error("Location In Your Files:"); + Error.captureStackTrace(errorStack); + + return new Promise((resolve, reject) => { + const callback = async () => { + try { + const rateLimitResetIn = await this.checkRatelimits(url); + if (rateLimitResetIn) { + return { rateLimited: rateLimitResetIn, beforeFetch: true, bucketID }; + } + + const query = method === "get" && body + ? Object.entries(body as any).map(([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(value as any)}` + ) + .join("&") + : ""; + const urlToUse = method === "get" && query ? `${url}?${query}` : url; + + const response = await fetch(urlToUse, this.createRequestBody(body, method)); + const bucketIDFromHeaders = this.processHeaders(url, response.headers); + this.handleStatusCode(response, errorStack); + + // Sometimes Discord returns an empty 204 response that can't be made to JSON. + if (response.status === 204) return resolve(); + + const json = await response.json(); + if ( + json.retry_after || + json.message === "You are being rate limited." + ) { + if (retryCount > 10) { + throw new Error("Max RateLimit Retries hit"); + } + + return { + rateLimited: json.retry_after, + beforeFetch: false, + bucketID: bucketIDFromHeaders, + }; + } + return resolve(json); + } catch (error) { + return reject(error); + } + }; + + this.addToQueue({ + callback, + bucketID, + url, + }); + if (!this.queueInProcess) { + this.queueInProcess = true; + this.processQueue(); + } + }); + } + + async logErrors(response: Response, errorStack?: unknown) { + try { + const error = await response.json(); + console.error(error); + } catch { + console.error(response); + } + } + + handleStatusCode(response: Response, errorStack?: unknown) { + const status = response.status; + + if ( + (status >= 200 && status < 400) || + status === HttpResponseCode.TooManyRequests + ) { + return true; + } + + this.logErrors(response, errorStack); + + switch (status) { + case HttpResponseCode.BadRequest: + case HttpResponseCode.Unauthorized: + case HttpResponseCode.Forbidden: + case HttpResponseCode.NotFound: + case HttpResponseCode.MethodNotAllowed: + throw new Error("Request Client Error"); + case HttpResponseCode.GatewayUnavailable: + throw new Error("Request Server Error"); + } + + // left are all unknown + throw new Error("Request Unknown Error"); + } + + processHeaders(url: string, headers: Headers) { + let ratelimited = false; + + // Get all useful headers + const remaining = headers.get("x-ratelimit-remaining"); + const resetTimestamp = headers.get("x-ratelimit-reset"); + const retryAfter = headers.get("retry-after"); + const global = headers.get("x-ratelimit-global"); + const bucketID = headers.get("x-ratelimit-bucket"); + + // If there is no remaining rate limit for this endpoint, we save it in cache + if (remaining && remaining === "0") { + ratelimited = true; + + this.ratelimitedPaths.set(url, { + url, + resetTimestamp: Number(resetTimestamp) * 1000, + bucketID, + }); + + if (bucketID) { + this.ratelimitedPaths.set(bucketID, { + url, + resetTimestamp: Number(resetTimestamp) * 1000, + bucketID, + }); + } + } + + // If there is no remaining global limit, we save it in cache + if (global) { + const reset = Date.now() + Number(retryAfter); + this.globallyRateLimited = true; + ratelimited = true; + + this.ratelimitedPaths.set("global", { + url: "global", + resetTimestamp: reset, + bucketID, + }); + + if (bucketID) { + this.ratelimitedPaths.set(bucketID, { + url: "global", + resetTimestamp: reset, + bucketID, + }); + } + } + + return ratelimited ? bucketID : undefined; + } + + get(url: string, body?: unknown) { + return this.runMethod("get", url, body); + } + post(url: string, body?: unknown) { + return this.runMethod("post", url, body); + } + delete(url: string, body?: unknown) { + return this.runMethod("delete", url, body); + } + patch(url: string, body?: unknown) { + return this.runMethod("patch", url, body); + } + put(url: string, body?: unknown) { + return this.runMethod("put", url, body); + } +} \ No newline at end of file diff --git a/src/structures/guild.ts b/src/structures/guild.ts index 90234ac..efd2286 100644 --- a/src/structures/guild.ts +++ b/src/structures/guild.ts @@ -5,10 +5,11 @@ import { Base } from './base.ts' import { Channel } from './channel.ts' import { Emoji } from './emoji.ts' import { Member } from './member.ts' -import { Role } from './role.ts' import { VoiceState } from './voiceState.ts' import cache from '../models/cache.ts' import getChannelByType from '../utils/getChannelByType.ts' +import { RolesManager } from "../managers/RolesManager.ts" +import { Role } from "./role.ts" export class Guild extends Base { id: string @@ -28,7 +29,7 @@ export class Guild extends Base { verificationLevel?: string defaultMessageNotifications?: string explicitContentFilter?: string - roles?: Role[] + roles: RolesManager = new RolesManager(this.client, this) emojis?: Emoji[] features?: GuildFeatures[] mfaLevel?: string @@ -79,9 +80,12 @@ export class Guild extends Base { this.verificationLevel = data.verification_level this.defaultMessageNotifications = data.default_message_notifications this.explicitContentFilter = data.explicit_content_filter - this.roles = data.roles.map( - v => cache.get('role', v.id) ?? new Role(client, v) - ) + // this.roles = data.roles.map( + // v => cache.get('role', v.id) ?? new Role(client, v) + // ) + data.roles.forEach(role => { + this.roles.set(role.id, new Role(client, role)) + }) this.emojis = data.emojis.map( v => cache.get('emoji', v.id) ?? new Emoji(client, v) ) @@ -120,7 +124,6 @@ export class Guild extends Base { this.approximateNumberCount = data.approximate_number_count this.approximatePresenceCount = data.approximate_presence_count } - cache.set('guild', this.id, this) } protected readFromData (data: GuildPayload): void { @@ -147,10 +150,10 @@ export class Guild extends Base { data.default_message_notifications ?? this.defaultMessageNotifications this.explicitContentFilter = data.explicit_content_filter ?? this.explicitContentFilter - this.roles = - data.roles.map( - v => cache.get('role', v.id) ?? new Role(this.client, v) - ) ?? this.roles + // this.roles = + // data.roles.map( + // v => cache.get('role', v.id) ?? new Role(this.client, v) + // ) ?? this.roles this.emojis = data.emojis.map( v => cache.get('emoji', v.id) ?? new Emoji(this.client, v) diff --git a/src/structures/user.ts b/src/structures/user.ts index 4d0ef1c..ff14c84 100644 --- a/src/structures/user.ts +++ b/src/structures/user.ts @@ -18,6 +18,10 @@ export class User extends Base { premiumType?: 0 | 1 | 2 publicFlags?: number + get tag(): string { + return `${this.username}#${this.discriminator}`; + } + get nickMention (): string { return `<@!${this.id}>` } @@ -59,4 +63,8 @@ export class User extends Base { this.premiumType = data.premium_type ?? this.premiumType this.publicFlags = data.public_flags ?? this.publicFlags } + + toString() { + return this.mention; + } } diff --git a/src/test/config.ts.sample b/src/test/config.ts.sample index 44a8029..14fec6b 100644 --- a/src/test/config.ts.sample +++ b/src/test/config.ts.sample @@ -1,2 +1 @@ -const TOKEN = '' -export { TOKEN } +export const TOKEN = '' \ No newline at end of file diff --git a/src/test/index.ts b/src/test/index.ts index 2763c68..1d9c6c4 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -10,9 +10,11 @@ import { User } from '../structures/user.ts' const bot = new Client() bot.on('ready', () => { - console.log('READY!') + console.log(`[Login] Logged in as ${bot.user?.tag}!`) }) +bot.on('debug', console.log) + bot.on('channelDelete', (channel: Channel) => { console.log('channelDelete', channel.id) }) diff --git a/src/types/gatewayBot.ts b/src/types/gatewayBot.ts new file mode 100644 index 0000000..f76da8c --- /dev/null +++ b/src/types/gatewayBot.ts @@ -0,0 +1,11 @@ +export interface ISessionStartLimit { + total: number + remaining: number + reset_after: number +} + +export interface GatewayBotPayload { + url: string + shards: number + session_start_limit: ISessionStartLimit +} \ No newline at end of file diff --git a/src/utils/collection.ts b/src/utils/collection.ts new file mode 100644 index 0000000..fc94d05 --- /dev/null +++ b/src/utils/collection.ts @@ -0,0 +1,87 @@ +export class Collection extends Map { + maxSize?: number; + + set(key: K, value: V) { + if (this.maxSize || this.maxSize === 0) { + if (this.size >= this.maxSize) return this + } + + return super.set(key, value) + } + + array() { + return [...this.values()] + } + + first(): V { + return this.values().next().value + } + + last(): V { + return [...this.values()][this.size - 1] + } + + random() { + let arr = [...this.values()] + return arr[Math.floor(Math.random() * arr.length)] + } + + find(callback: (value: V, key: K) => boolean) { + for (const key of this.keys()) { + const value = this.get(key)! + if (callback(value, key)) return value + } + // If nothing matched + return; + } + + filter(callback: (value: V, key: K) => boolean) { + const relevant = new Collection() + this.forEach((value, key) => { + if (callback(value, key)) relevant.set(key, value) + }); + + return relevant; + } + + map(callback: (value: V, key: K) => T) { + const results = [] + for (const key of this.keys()) { + const value = this.get(key)! + results.push(callback(value, key)) + } + return results + } + + some(callback: (value: V, key: K) => boolean) { + for (const key of this.keys()) { + const value = this.get(key)! + if (callback(value, key)) return true + } + + return false + } + + every(callback: (value: V, key: K) => boolean) { + for (const key of this.keys()) { + const value = this.get(key)! + if (!callback(value, key)) return false + } + + return true + } + + reduce( + callback: (accumulator: T, value: V, key: K) => T, + initialValue?: T, + ): T { + let accumulator: T = initialValue! + + for (const key of this.keys()) { + const value = this.get(key)! + accumulator = callback(accumulator, value, key) + } + + return accumulator + } +} \ No newline at end of file diff --git a/src/utils/delay.ts b/src/utils/delay.ts new file mode 100644 index 0000000..d0c5de7 --- /dev/null +++ b/src/utils/delay.ts @@ -0,0 +1,3 @@ +export const delay = (ms: number) => new Promise((resolve, reject) => { + setTimeout(() => resolve(true), ms); +}); \ No newline at end of file diff --git a/src/utils/index.ts b/src/utils/index.ts index 75766ce..5d3be21 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,3 @@ -import getChannelByType from './getChannelByType.ts' - -export default { getChannelByType } +export { default as getChannelByType } from './getChannelByType.ts' +export type AnyFunction = (...args:any[]) => ReturnType; +export { delay } from './delay.ts' \ No newline at end of file