diff --git a/.eslintrc b/.eslintrc index 9c999ad..219bcb0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -66,6 +66,12 @@ "always" ], "no-var": "error", + "eqeqeq": "error", + "no-eq-null": "error", + "arrow-parens": [ + "error", + "always" + ], // TypeScript Rules "@typescript-eslint/naming-convention": [ "error", @@ -127,8 +133,8 @@ "builtin", "external", "internal", - "sibling", "parent", + "sibling", "index", "object", "type" diff --git a/src/api/apiClient.ts b/src/api/apiClient.ts index 5bfbfa3..81154c8 100644 --- a/src/api/apiClient.ts +++ b/src/api/apiClient.ts @@ -1,11 +1,11 @@ -import { ApiHandler } from './apiHandler'; +import { ApiManager } from './apiManager'; import type { Client } from '../client/client'; import type { IRequestOptions } from '../utils/types'; export class ApiClient { public client: Client; - public handler: ApiHandler; + public manager: ApiManager; private _token: string; @@ -13,11 +13,11 @@ export class ApiClient { this.client = client; this._token = token; - this.handler = new ApiHandler(this.client, this._token); + this.manager = new ApiManager(this.client, this._token); } - public async delete(options: IRequestOptions): Promise { - return this.handler.makeRequest({ + public async delete(options: IRequestOptions): Promise { + return this.manager.createRequest({ method: 'DELETE', body: options.body, headers: options.headers, @@ -28,8 +28,8 @@ export class ApiClient { }); } - public async get(options: IRequestOptions): Promise { - return this.handler.makeRequest({ + public async get(options: IRequestOptions): Promise { + return this.manager.createRequest({ method: 'GET', body: options.body, headers: options.headers, @@ -40,8 +40,8 @@ export class ApiClient { }); } - public async patch(options: IRequestOptions): Promise { - return this.handler.makeRequest({ + public async patch(options: IRequestOptions): Promise { + return this.manager.createRequest({ method: 'PATCH', body: options.body, headers: options.headers, @@ -52,8 +52,8 @@ export class ApiClient { }); } - public async post(options: IRequestOptions): Promise { - return this.handler.makeRequest({ + public async post(options: IRequestOptions): Promise { + return this.manager.createRequest({ method: 'POST', body: options.body, headers: options.headers, @@ -64,8 +64,8 @@ export class ApiClient { }); } - public async put(options: IRequestOptions): Promise { - return this.handler.makeRequest({ + public async put(options: IRequestOptions): Promise { + return this.manager.createRequest({ method: 'PUT', body: options.body, headers: options.headers, diff --git a/src/api/apiHandler.ts b/src/api/apiHandler.ts index 4d9bb77..c75c9ec 100644 --- a/src/api/apiHandler.ts +++ b/src/api/apiHandler.ts @@ -3,68 +3,134 @@ import { queue, QueueObject } from 'async'; import FormData from 'form-data'; import fetch from 'node-fetch'; +import type { ApiManager } from './apiManager'; import type { Client } from '../client/client'; -import type { IMakeRequestOptions } from '../utils/types'; +import type { IMakeRequestOptions, IRequestOptions, IRouteIdentifier } from '../utils/types'; +import { sleep } from '../utils/sleep'; export class ApiHandler { public client: Client; + public hash: string; + public id: string; + public limit: number; + public majorParameter: string; + public manager: ApiManager; public queue: QueueObject; + public remaining: number; + public reset: number; private _token: string; - public constructor(client: Client, token: string) { + public constructor(client: Client, manager: ApiManager, token: string, hash: string, majorParameter: string) { this.client = client; this._token = token; - this.queue = queue(async (requestOptions: IMakeRequestOptions, callback) => { - callback(await this.makeRequest(requestOptions)); + this.hash = hash; + this.id = `${hash}:${majorParameter}`; + this.limit = -1; + this.majorParameter = majorParameter; + this.manager = manager; + this.queue = queue(async (task: { routeId: IRouteIdentifier, requestOptions: IMakeRequestOptions; }, callback) => { + if (this.limited) await sleep(this.timeToReset); + callback(await this.makeRequest(task.routeId, task.requestOptions)); }); + this.remaining = -1; + this.reset = -1; } - public get baseApiUrl(): string { - return `${this.client.options.api.apiUrl}/v${this.client.options.api.apiVersion}`; - } - - public async makeRequest(options: IMakeRequestOptions): Promise { + public async makeRequest(routeId: IRouteIdentifier, options: IMakeRequestOptions, retries = 0): Promise { const headers: Map = new Map(); if (options.headers) for (const prop in options.headers) headers.set(prop, options.headers[prop]!); - - if (options.requireAuth) headers.set('Authorization', `Bot ${this._token}`); if (options.reason) headers.set('X-Audit-Log-Reason', encodeURIComponent(options.reason)); + if (options.requireAuth) headers.set('Authorization', `Bot ${this._token}`); let body: FormData | string; if (options.files && options.files.length) { body = new FormData(); - for (const file of options.files) if (file && file.file) body.append(file.name, file.file, file.name); + if (options.body) body.append('payload_json', JSON.stringify(options.body)); + + for (const file of options.files) if (file && file.file) body.append(file.name, file.file, file.name); } else if (options.body) { body = JSON.stringify(options.body); headers.set('Content-Type', 'application/json'); } const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.client.options.api.apiRequestTimeout); + const timeout = setTimeout(() => controller.abort(), this.client.options.api.requestTimeout); try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const res = await fetch(`${this.baseApiUrl}${options.path}`, { method: options.method, headers, signal: controller.signal, body }); - // TODO: handle Ratelimits + const bucketHash = res.headers.get('x-ratelimit-bucket'); + const globalRatelimit = res.headers.get('x-ratelimit-global'); + const limit = res.headers.get('x-ratelimit-limit'); + const remaining = res.headers.get('x-ratelimit-remaining'); + const reset = res.headers.get('x-ratelimit-reset'); + const resetAfter = res.headers.get('x-ratelimit-reset-after'); + const retry = res.headers.get('retry-after'); + const serverDate = res.headers.get('date'); + + if (!resetAfter && options.path.includes('reactions')) { + this.reset = new Date(Number(serverDate)).getTime() - (new Date(Number(serverDate)).getTime() - Date.now()) + 250; + } + + let retryAfter = 0; + + this.limit = limit !== null ? Number(limit) : Number.MAX_VALUE; + this.remaining = remaining !== null ? Number(remaining) : Number.MAX_VALUE; + this.reset = reset || resetAfter + ? Number(reset) * 1000 + Date.now() + this.client.options.api.offset + : Date.now(); + + if (retry !== null) + retryAfter = retry ? Number(retry) * 1000 : -1; + + if (bucketHash !== null && bucketHash !== this.hash) { + if (!this.manager.hashes.has(`${options.method}-${routeId.route}`)) + this.manager.hashes.set(`${options.method}-${routeId.route}`, bucketHash); + } + + if (globalRatelimit) { + this.manager.globalTimeout = true; + this.manager.globalReset = Date.now() + retryAfter; + } // TODO: Handle non-2xx responses (Will slowly add them as i get them) if (res.ok) { return res.json() as unknown as T; + } else if (res.status === 429) { + await sleep(retryAfter); + return this.makeRequest(routeId, options, retries++); } else { - return null; + throw new Error('An unknown status code was returned!'); } } finally { clearTimeout(timeout); } } - public async push(requestOptions: IMakeRequestOptions): Promise { - return await this.queue.pushAsync(requestOptions); + public async push(routeId: IRouteIdentifier, requestOptions: IRequestOptions): Promise { + return await this.queue.pushAsync({ routeId, requestOptions }); + } + + public get baseApiUrl(): string { + return `${this.client.options.api.url}/v${this.client.options.api.version}`; + } + + public get inactive(): boolean { + return this.queue.length() === 0 && !this.limited; + } + + public get limited(): boolean { + if (typeof this.manager.globalTimeout === 'number') return this.manager.globalTimeout <= 0 && Date.now() < this.manager.globalReset; + else return this.remaining <= 0 && Date.now() < this.reset; + } + + public get timeToReset(): number { + return this.reset - Date.now(); } } diff --git a/src/api/apiManager.ts b/src/api/apiManager.ts new file mode 100644 index 0000000..cb0ee43 --- /dev/null +++ b/src/api/apiManager.ts @@ -0,0 +1,64 @@ +import { ApiHandler } from './apiHandler'; +import { Snowflake } from '../utils/snowflake'; + +import type { Client } from '../client/client'; +import type { ApiMethods, IMakeRequestOptions, IRouteIdentifier } from '../utils/types'; + +export class ApiManager { + public client: Client; + public globalReset: number; + public globalTimeout: boolean | null; + public hashes: Map; + public queues: Map; + + private _token: string; + + public constructor(client: Client, token: string) { + this.client = client; + this._token = token; + + this.globalReset = 0; + this.globalTimeout = null; + this.hashes = new Map(); + this.queues = new Map(); + } + + public async createRequest(requestOptions: IMakeRequestOptions): Promise { + const routeId = this.generateRouteIdentifiers(requestOptions.path, requestOptions.method); + let hash = this.hashes.get(`${requestOptions.method}-${routeId.route}`); + + if (!hash) hash = `UnknownHash(${routeId.route})`; + + let queue = this.queues.get(`${hash}:${routeId.majorParameter}`); + + if (queue === null) { + const apiHandler = new ApiHandler(this.client, this, this._token, hash, routeId.majorParameter); + this.queues.set(apiHandler.id, apiHandler); + + queue = this.queues.get(`${hash}:${routeId.majorParameter}`); + } + + return await queue!.push(routeId, requestOptions); + } + + public generateRouteIdentifiers(endpoint: string, method: ApiMethods): IRouteIdentifier { + const result = new RegExp('^/(?:channels|guilds|webhooks)/(d{16,19})').exec(endpoint); + const majorParameter = result && result.length !== 0 ? result[1]!.toString() : 'global'; + const baseRoute = endpoint.replace(new RegExp('d{16,19}'), ':id'); + + let exceptions = ''; + + if (method === 'DELETE' && baseRoute === '/channels/:id/messages/:id') { + const id = new RegExp('d{16,19}$').exec(endpoint); + const snowflake = Snowflake.fromSnowflake(id![0]!); + + if (Date.now() - (new Date(snowflake).getTime() / 1000) > 1000 * 60 * 60 * 24 * 14) + exceptions += '[Delete Old Message]'; + } + + return { + route: `${baseRoute}${exceptions}`, + majorParameter, + }; + } +} diff --git a/src/index.ts b/src/index.ts index 6f70765..db8dd51 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,3 @@ export { IMakeRequestOptions, Primitives, } from './utils/types'; -export { } from './utils/utils'; diff --git a/src/utils/defaults.ts b/src/utils/defaults.ts index a2a699f..979fcfa 100644 --- a/src/utils/defaults.ts +++ b/src/utils/defaults.ts @@ -3,9 +3,12 @@ import type { IDefaultOptions } from './types'; export const defaults: IDefaultOptions = { clientOptions: { api: { - apiRequestTimeout: 5000, - apiUrl: 'https://discord.com/api', - apiVersion: 9, + offset: 500, + requestTimeout: 5000, + retryLimit: 5, + sweepInterval: 60000, + url: 'https://discord.com/api', + version: 9, }, }, }; diff --git a/src/utils/sleep.ts b/src/utils/sleep.ts new file mode 100644 index 0000000..bc9ac32 --- /dev/null +++ b/src/utils/sleep.ts @@ -0,0 +1,5 @@ +export const sleep = async (ms: number): Promise => { + return new Promise((res) => { + setTimeout(() => res(), ms); + }); +}; diff --git a/src/utils/snowflake.ts b/src/utils/snowflake.ts new file mode 100644 index 0000000..6dc549f --- /dev/null +++ b/src/utils/snowflake.ts @@ -0,0 +1,9 @@ +export class Snowflake { + public static fromSnowflake(value: string): number { + return new Date((Number(value) >> 22) + 1420070400000).getTime() / 1000; + } + + public static toSnowflake(value: number): string { + return (((new Date(value).getTime() / 1000) - 1420070400000) << 22).toString(); + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 742437d..d90c4aa 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,8 +1,11 @@ // Interfaces export interface IApiClientOptions { - apiUrl?: string; - apiRequestTimeout?: number; - apiVersion?: number; + offset?: number; + requestTimeout?: number; + retryLimit?: number; + sweepInterval?: number; + url?: string; + version?: number; } export interface IClientOptions { @@ -23,48 +26,54 @@ export interface IMakeRequestOptions extends IRequestOptions { } export interface IMessageEmbed { - description: string; - title?: string; - type?: 'rich'; - url?: string; - color?: number; - timestamp?: number; author?: { name?: string, url?: string }; - fields?: { name: string, value: string, inline?: boolean }[]; - footer?: { text?: string, icon_url?: string }; + color?: number; + description?: string; + fields?: { inline?: boolean, name: string, value: string }[]; + footer?: { icon_url?: string, text?: string }; image?: { url?: string }; provider?: { name?: string, url?: string }; + timestamp?: number; + title?: string; thumbnail?: { url?: string }; + type?: 'rich'; + url?: string; video?: { url?: string }; } export interface IRequestOptions { + body?: unknown; + files?: IFile[]; + headers?: Record; path: string; reason?: string; requireAuth?: boolean; - body?: unknown; - headers?: Record; - files?: IFile[]; +} + +export interface IRouteIdentifier { + majorParameter: string; + route: string; } // Api Rest Request Interfaces // TODO: Add message components export interface IApiCreateMessage { - content?: string; - tts?: boolean; - embeds?: IMessageEmbed[]; - allowed_mentions: { + allowed_mentions?: { + parse?: 'everyone' | 'roles' | 'users'[]; + replied_user?: boolean; roles?: string[]; users?: string[]; - replied_user?: boolean; - parse?: 'everyone' | 'roles' | 'users'[]; }; - message_reference: { + content?: string; + embeds?: IMessageEmbed[]; + files?: IFile[]; + message_reference?: { channel_id?: string; + fail_if_not_exists?: boolean; guild_id?: string; message_id?: string; - fail_if_not_exists?: boolean; }; + tts?: boolean; } // Enums @@ -72,7 +81,7 @@ export interface IApiCreateMessage { // Type Aliases export type ApiMethods = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' ; // eslint-disable-next-line @typescript-eslint/ban-types -export type Builtin = Primitives | Function | Date | Error | RegExp; +export type Builtin = Date | Error | Function | Primitives | RegExp; export type DeepRequired = T extends Builtin ? NonNullable : T extends Map @@ -93,4 +102,4 @@ export type DeepRequired = T extends Builtin : T extends {} ? { [K in keyof T]-?: DeepRequired } : NonNullable; -export type Primitives = string | number | boolean | bigint | symbol | undefined | null; +export type Primitives = bigint | boolean | null | number | string | symbol | undefined; diff --git a/src/utils/utils.ts b/src/utils/utils.ts deleted file mode 100644 index e69de29..0000000