diff --git a/src/client/shard.ts b/src/client/shard.ts index 547b467..1cddfe1 100644 --- a/src/client/shard.ts +++ b/src/client/shard.ts @@ -61,10 +61,24 @@ export class ShardManager extends HarmonyEventEmitter { let shardCount: number if (this.cachedShardCount !== undefined) shardCount = this.cachedShardCount else { - if (this.client.shardCount === 'auto') { + if ( + this.client.shardCount === 'auto' && + this.client.fetchGatewayInfo !== false + ) { + this.debug('Fetch /gateway/bot...') const info = await this.client.rest.api.gateway.bot.get() + 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`) shardCount = info.shards as number - } else shardCount = this.client.shardCount ?? 1 + } else + shardCount = + typeof this.client.shardCount === 'string' + ? 1 + : this.client.shardCount ?? 1 } this.cachedShardCount = shardCount return this.cachedShardCount diff --git a/src/gateway/mod.ts b/src/gateway/mod.ts index dbf62b6..0b3b6ff 100644 --- a/src/gateway/mod.ts +++ b/src/gateway/mod.ts @@ -266,21 +266,6 @@ export class Gateway extends HarmonyEventEmitter { if (typeof this.client.intents !== 'object') throw new Error('Intents not specified') - if (this.client.fetchGatewayInfo === true) { - this.debug('Fetching /gateway/bot...') - const info = await this.client.rest.api.gateway.bot.get() - 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`) - } - if (forceNewSession === undefined || !forceNewSession) { const sessionIDCached = await this.cache.get( `session_id_${this.shards?.join('-') ?? '0'}` diff --git a/src/managers/channels.ts b/src/managers/channels.ts index b949440..69efe6c 100644 --- a/src/managers/channels.ts +++ b/src/managers/channels.ts @@ -121,6 +121,10 @@ export class ChannelsManager extends BaseManager { : undefined } + if (payload.content === undefined && payload.embed === undefined) { + payload.content = '' + } + const resp = await this.client.rest.api.channels[channelID].messages.post( payload ) diff --git a/src/rest/bucket.ts b/src/rest/bucket.ts new file mode 100644 index 0000000..7fd0dfd --- /dev/null +++ b/src/rest/bucket.ts @@ -0,0 +1,239 @@ +// based on https://github.com/discordjs/discord.js/blob/master/src/rest/RequestHandler.js +// adapted to work with harmony rest manager + +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ +import { delay } from '../utils/delay.ts' +import { DiscordAPIError, HTTPError } from './error.ts' +import type { RESTManager } from './manager.ts' +import { RequestQueue } from './queue.ts' +import { APIRequest } from './request.ts' + +function parseResponse(res: Response, raw: boolean): any { + if (raw) return res + if (res.status === 204) return undefined + if (res.headers.get('content-type')?.startsWith('application/json') === true) + return res.json() + return res.arrayBuffer().then((e) => new Uint8Array(e)) +} + +function getAPIOffset(serverDate: number | string): number { + return new Date(serverDate).getTime() - Date.now() +} + +function calculateReset( + reset: number | string, + serverDate: number | string +): number { + return new Date(Number(reset) * 1000).getTime() - getAPIOffset(serverDate) +} + +let invalidCount = 0 +let invalidCountResetTime: number | null = null + +export class BucketHandler { + queue = new RequestQueue() + reset = -1 + remaining = -1 + limit = -1 + + constructor(public manager: RESTManager) {} + + async push(request: APIRequest): Promise { + await this.queue.wait() + try { + return await this.execute(request) + } finally { + this.queue.shift() + } + } + + get globalLimited(): boolean { + return ( + this.manager.globalRemaining <= 0 && + Date.now() < Number(this.manager.globalReset) + ) + } + + get localLimited(): boolean { + return this.remaining <= 0 && Date.now() < this.reset + } + + get limited(): boolean { + return this.globalLimited || this.localLimited + } + + get inactive(): boolean { + return this.queue.remaining === 0 && !this.limited + } + + async globalDelayFor(ms: number): Promise { + return await new Promise((resolve) => { + this.manager.setTimeout(() => { + this.manager.globalDelay = null + resolve() + }, ms) + }) + } + + async execute(request: APIRequest): Promise { + while (this.limited) { + const isGlobal = this.globalLimited + let limit, timeout, delayPromise + + if (isGlobal) { + limit = this.manager.globalLimit + timeout = + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + Number(this.manager.globalReset) + + this.manager.restTimeOffset - + Date.now() + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!this.manager.globalDelay) { + this.manager.globalDelay = this.globalDelayFor(timeout) as any + } + delayPromise = this.manager.globalDelay + } else { + limit = this.limit + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + timeout = this.reset + this.manager.restTimeOffset - Date.now() + delayPromise = delay(timeout) + } + + this.manager.client?.emit('rateLimit', { + timeout, + limit, + method: request.method, + path: request.path, + global: isGlobal + }) + + await delayPromise + } + + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!this.manager.globalReset || this.manager.globalReset < Date.now()) { + this.manager.globalReset = Date.now() + 1000 + this.manager.globalRemaining = this.manager.globalLimit + } + this.manager.globalRemaining-- + + // Perform the request + let res + try { + res = await request.execute() + } catch (error) { + if (request.retries === this.manager.retryLimit) { + throw new HTTPError( + error.message, + error.constructor.name, + error.status, + request.method, + request.path + ) + } + + request.retries++ + return await this.execute(request) + } + + let sublimitTimeout + if (res?.headers !== undefined) { + const serverDate = res.headers.get('date') + const limit = res.headers.get('x-ratelimit-limit') + const remaining = res.headers.get('x-ratelimit-remaining') + const reset = res.headers.get('x-ratelimit-reset') + this.limit = limit !== null ? Number(limit) : Infinity + this.remaining = remaining !== null ? Number(remaining) : 1 + this.reset = + reset !== null ? calculateReset(reset, serverDate!) : Date.now() + + if (request.path.includes('reactions') === true) { + this.reset = + new Date(serverDate!).getTime() - getAPIOffset(serverDate!) + 250 + } + + let retryAfter: number | null | string = res.headers.get('retry-after') + retryAfter = retryAfter !== null ? Number(retryAfter) * 1000 : -1 + if (retryAfter > 0) { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (res.headers.get('x-ratelimit-global')) { + this.manager.globalRemaining = 0 + this.manager.globalReset = Date.now() + retryAfter + } else if (!this.localLimited) { + sublimitTimeout = retryAfter + } + } + } + + if (res.status === 401 || res.status === 403 || res.status === 429) { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (!invalidCountResetTime || invalidCountResetTime < Date.now()) { + invalidCountResetTime = Date.now() + 1000 * 60 * 10 + invalidCount = 0 + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + invalidCount++ + } + + if (res.ok === true) { + return parseResponse(res, request.options.rawResponse ?? false) + } + + if (res.status >= 400 && res.status < 500) { + if (res.status === 429) { + this.manager.client?.emit( + 'debug', + `Rate-limited on route ${request.path}${ + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + sublimitTimeout ? ' for sublimit' : '' + }` + ) + + if (sublimitTimeout !== undefined) { + await delay(sublimitTimeout) + } + return await this.execute(request) + } + + let data + try { + data = await parseResponse(res, request.options.rawResponse ?? false) + } catch (err) { + throw new HTTPError( + err.message, + err.constructor.name, + err.status, + request.method, + request.path + ) + } + + throw new DiscordAPIError({ + url: request.path, + errors: data?.errors, + status: res.status, + method: request.method, + message: data?.message, + code: data?.code, + requestData: request.options.data + }) + } + + if (res.status >= 500 && res.status < 600) { + if (request.retries === this.manager.retryLimit) { + throw new HTTPError( + res.statusText, + res.constructor.name, + res.status, + request.method, + request.path + ) + } + + request.retries++ + return await this.execute(request) + } + + return null + } +} diff --git a/src/rest/error.ts b/src/rest/error.ts new file mode 100644 index 0000000..3f4d4ed --- /dev/null +++ b/src/rest/error.ts @@ -0,0 +1,44 @@ +import { simplifyAPIError } from '../utils/err_fmt.ts' +import { DiscordAPIErrorPayload } from './types.ts' + +export class DiscordAPIError extends Error { + name = 'DiscordAPIError' + error?: DiscordAPIErrorPayload + + constructor(error: string | DiscordAPIErrorPayload) { + super() + const fmt = Object.entries( + typeof error === 'object' ? simplifyAPIError(error.errors ?? {}) : {} + ) + this.message = + typeof error === 'string' + ? `${error} ` + : `\n${error.method.toUpperCase()} ${error.url.slice(7)} returned ${ + error.status + }\n(${error.code ?? 'unknown'}) ${error.message}${ + fmt.length === 0 + ? '' + : `\n${fmt + .map( + (e) => + ` at ${e[0]}:\n${e[1] + .map((e) => ` - ${e}`) + .join('\n')}` + ) + .join('\n')}\n` + }` + if (typeof error === 'object') this.error = error + } +} + +export class HTTPError extends Error { + constructor( + public message: string, + public name: string, + public code: number, + public method: string, + public path: string + ) { + super(message) + } +} diff --git a/src/rest/manager.ts b/src/rest/manager.ts index 6e5d334..7cd0cb1 100644 --- a/src/rest/manager.ts +++ b/src/rest/manager.ts @@ -1,66 +1,10 @@ -import { Embed } from '../structures/embed.ts' -import { MessageAttachment } from '../structures/message.ts' import { Collection } from '../utils/collection.ts' import type { Client } from '../client/mod.ts' -import { simplifyAPIError } from '../utils/err_fmt.ts' -import { - DiscordAPIErrorPayload, - HttpResponseCode, - RequestHeaders, - RequestMethods, - METHODS -} from './types.ts' +import { RequestMethods, METHODS } from './types.ts' import { Constants } from '../types/constants.ts' import { RESTEndpoints } from './endpoints.ts' - -export class DiscordAPIError extends Error { - name = 'DiscordAPIError' - error?: DiscordAPIErrorPayload - - constructor(error: string | DiscordAPIErrorPayload) { - super() - const fmt = Object.entries( - typeof error === 'object' ? simplifyAPIError(error.errors) : {} - ) - this.message = - typeof error === 'string' - ? `${error} ` - : `\n${error.method} ${error.url.slice(7)} returned ${error.status}\n(${ - error.code ?? 'unknown' - }) ${error.message}${ - fmt.length === 0 - ? '' - : `\n${fmt - .map( - (e) => - ` at ${e[0]}:\n${e[1] - .map((e) => ` - ${e}`) - .join('\n')}` - ) - .join('\n')}\n` - }` - if (typeof error === 'object') this.error = error - } -} - -export interface QueuedItem { - bucket?: string | null - url: string - onComplete: () => Promise< - | { - rateLimited: any - bucket?: string | null - before: boolean - } - | undefined - > -} - -export interface RateLimit { - url: string - resetAt: number - bucket: string | null -} +import { BucketHandler } from './bucket.ts' +import { APIRequest, RequestOptions } from './request.ts' export type MethodFunction = ( body?: unknown, @@ -126,6 +70,10 @@ export interface RESTOptions { userAgent?: string /** Optional Harmony client */ client?: Client + /** Requests Timeout (in MS, default 30s) */ + requestTimeout?: number + /** Retry Limit (default 1) */ + retryLimit?: number } /** Token Type for REST API. */ @@ -140,12 +88,6 @@ export enum TokenType { /** An easier to use interface for interacting with Discord REST API. */ export class RESTManager { - queues: { [key: string]: QueuedItem[] } = {} - rateLimits = new Collection() - /** Whether we are globally ratelimited or not */ - globalRateLimit: boolean = false - /** Whether requests are being processed or not */ - processing: boolean = false /** API Version being used by REST Manager */ version: number = 8 /** @@ -173,6 +115,17 @@ export class RESTManager { /** Optional Harmony Client object */ client?: Client endpoints: RESTEndpoints + requestTimeout = 30000 + timers: Set = new Set() + apiURL = Constants.DISCORD_API_URL + + handlers = new Collection() + globalLimit = Infinity + globalRemaining = this.globalLimit + globalReset: number | null = null + globalDelay: number | null = null + retryLimit = 1 + restTimeOffset = 0 constructor(options?: RESTOptions) { this.api = builder(this) @@ -183,294 +136,35 @@ export class RESTManager { if (options?.userAgent !== undefined) this.userAgent = options.userAgent if (options?.canary !== undefined) this.canary = options.canary if (options?.client !== undefined) this.client = options.client + if (options?.retryLimit !== undefined) this.retryLimit = options.retryLimit + if (options?.requestTimeout !== undefined) + this.requestTimeout = options.requestTimeout this.endpoints = new RESTEndpoints(this) - this.handleRateLimits() } - /** Checks the queues of buckets, if empty, delete entry */ - private checkQueues(): void { - Object.entries(this.queues).forEach(([key, value]) => { - if (value.length === 0) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.queues[key] - } - }) + setTimeout(fn: (...args: any[]) => any, ms: number): number { + const timer = setTimeout(async () => { + this.timers.delete(timer) + await fn() + }, ms) + this.timers.add(timer) + return timer } - /** Adds a Request to Queue */ - private queue(request: QueuedItem): void { - const route = request.url.substring( - Number(Constants.DISCORD_API_URL.length) + 1 - ) - const parts = route.split('/') - parts.shift() - const [id] = parts + async request( + method: RequestMethods, + path: string, + options: RequestOptions = {} + ): Promise { + const req = new APIRequest(this, method, path, options) + let handler = this.handlers.get(req.path) - if (this.queues[id] !== undefined) { - this.queues[id].push(request) - } else { - this.queues[id] = [request] - } - } - - private async processQueue(): Promise { - if (Object.keys(this.queues).length !== 0 && !this.globalRateLimit) { - await Promise.allSettled( - Object.values(this.queues).map(async (pathQueue) => { - const request = pathQueue.shift() - if (request === undefined) return - - const rateLimitedURLResetIn = await this.isRateLimited(request.url) - - if (typeof request.bucket === 'string') { - const rateLimitResetIn = await this.isRateLimited(request.bucket) - if (rateLimitResetIn !== false) { - this.queue(request) - } else { - const result = await request.onComplete() - if (result?.rateLimited !== undefined) { - this.queue({ - ...request, - bucket: result.bucket ?? request.bucket - }) - } - } - } else { - if (rateLimitedURLResetIn !== false) { - this.queue(request) - } else { - const result = await request.onComplete() - if (result?.rateLimited !== undefined) { - this.queue({ - ...request, - bucket: result.bucket ?? request.bucket - }) - } - } - } - }) - ) + if (handler === undefined) { + handler = new BucketHandler(this) + this.handlers.set(req.route, handler) } - if (Object.keys(this.queues).length !== 0) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.processQueue() - this.checkQueues() - } else this.processing = false - } - - private prepare(body: any, method: RequestMethods): { [key: string]: any } { - const headers: RequestHeaders = { - 'User-Agent': - this.userAgent ?? - `DiscordBot (harmony, https://github.com/harmonyland/harmony)` - } - - if (this.token !== undefined) { - const token = typeof this.token === 'string' ? this.token : this.token() - if (token !== undefined) - headers.Authorization = `${this.tokenType} ${token}`.trim() - } - - if (method === 'get' || method === 'head' || method === 'delete') - body = undefined - - if (body?.reason !== undefined) { - headers['X-Audit-Log-Reason'] = encodeURIComponent(body.reason) - } - - let _files: undefined | MessageAttachment[] - if (body?.embed?.files !== undefined && Array.isArray(body?.embed?.files)) { - _files = body?.embed?.files - } - if (body?.embeds !== undefined && Array.isArray(body?.embeds)) { - const files1 = body?.embeds - .map((e: Embed) => e.files) - .filter((e: MessageAttachment[]) => e !== undefined) - for (const files of files1) { - for (const file of files) { - if (_files === undefined) _files = [] - _files?.push(file) - } - } - } - - if ( - body?.file !== undefined || - body?.files !== undefined || - _files !== undefined - ) { - const files: Array<{ blob: Blob; name: string }> = [] - if (body?.file !== undefined) files.push(body.file) - if (body?.files !== undefined && Array.isArray(body.files)) { - for (const file of body.files) { - files.push(file) - } - } - if (_files !== undefined) { - for (const file of _files) { - files.push(file) - } - } - const form = new FormData() - files.forEach((file, index) => - form.append(`file${index + 1}`, file.blob, file.name) - ) - const json = JSON.stringify(body) - form.append('payload_json', json) - if (body === undefined) body = {} - body.file = form - } else if ( - body !== undefined && - !['get', 'delete'].includes(method.toLowerCase()) - ) { - headers['Content-Type'] = 'application/json' - } - - if (this.headers !== undefined) Object.assign(headers, this.headers) - const data: { [name: string]: any } = { - headers, - body: body?.file ?? JSON.stringify(body), - method: method.toUpperCase() - } - - return data - } - - private isRateLimited(url: string): number | false { - const global = this.rateLimits.get('global') - const rateLimited = this.rateLimits.get(url) - const now = Date.now() - - if (rateLimited !== undefined && now < rateLimited.resetAt) { - return rateLimited.resetAt - now - } - if (global !== undefined && now < global.resetAt) { - return global.resetAt - now - } - - return false - } - - /** Processes headers of the Response */ - private processHeaders( - url: string, - headers: Headers - ): string | null | undefined { - let rateLimited = false - - const global = headers.get('x-ratelimit-global') - const bucket = headers.get('x-ratelimit-bucket') - const remaining = headers.get('x-ratelimit-remaining') - const resetAt = headers.get('x-ratelimit-reset') - const retryAfter = headers.get('retry-after') - - if (remaining !== null && remaining === '0') { - rateLimited = true - - this.rateLimits.set(url, { - url, - resetAt: Number(resetAt) * 1000, - bucket - }) - - if (bucket !== null) { - this.rateLimits.set(bucket, { - url, - resetAt: Number(resetAt) * 1000, - bucket - }) - } - } - - if (global !== null) { - const reset = Date.now() + Number(retryAfter) - this.globalRateLimit = true - rateLimited = true - - this.rateLimits.set('global', { - url: 'global', - resetAt: reset, - bucket - }) - - if (bucket !== null) { - this.rateLimits.set(bucket, { - url: 'global', - resetAt: reset, - bucket - }) - } - } - - return rateLimited ? bucket : undefined - } - - /** Handles status code of response and acts as required */ - private handleStatusCode( - response: Response, - body: any, - data: { [key: string]: any }, - reject: CallableFunction - ): void { - const status = response.status - - // We have hit ratelimit - this should not happen - if (status === HttpResponseCode.TooManyRequests) { - if (this.client !== undefined) - this.client.emit('rateLimit', { - method: data.method, - url: response.url, - body - }) - reject(new Error('RateLimited')) - return - } - - // It's a normal status code... just continue - if ( - (status >= 200 && status < 400) || - status === HttpResponseCode.NoContent - ) - return - - let text: undefined | string = Deno.inspect( - body.errors === undefined ? body : body.errors - ) - if (text === 'undefined') text = undefined - - if (status === HttpResponseCode.Unauthorized) - reject( - new DiscordAPIError(`Request was Unauthorized. Invalid Token.\n${text}`) - ) - - const _data = { ...data } - if (_data?.headers !== undefined) delete _data.headers - if (_data?.method !== undefined) delete _data.method - - // At this point we know it is error - const error: DiscordAPIErrorPayload = { - url: new URL(response.url).pathname, - status, - method: data.method, - code: body?.code, - message: body?.message, - errors: body?.errors ?? {}, - requestData: _data - } - - if ( - [ - HttpResponseCode.BadRequest, - HttpResponseCode.NotFound, - HttpResponseCode.Forbidden, - HttpResponseCode.MethodNotAllowed - ].includes(status) - ) { - reject(new DiscordAPIError(error)) - } else if (status === HttpResponseCode.GatewayUnavailable) { - reject(new DiscordAPIError(error)) - } else reject(new DiscordAPIError('Request - Unknown Error')) + return handler.push(req) } /** @@ -486,109 +180,23 @@ export class RESTManager { method: RequestMethods, url: string, body?: unknown, - maxRetries = 0, + _maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options: RequestOptions = {} ): Promise { - return await new Promise((resolve, reject) => { - const onComplete = async (): Promise => { - try { - const rateLimitResetIn = await this.isRateLimited(url) - if (rateLimitResetIn !== false) { - return { - rateLimited: rateLimitResetIn, - before: true, - bucket - } - } - - const query = - method === 'get' && body !== undefined - ? Object.entries(body as any) - .filter(([k, v]) => v !== undefined) - .map( - ([key, value]) => - `${encodeURIComponent(key)}=${encodeURIComponent( - value as any - )}` - ) - .join('&') - : '' - let urlToUse = - method === 'get' && query !== '' ? `${url}?${query}` : url - - // It doesn't start with HTTP, that means it's an incomplete URL - if (!urlToUse.startsWith('http')) { - if (!urlToUse.startsWith('/')) urlToUse = `/${urlToUse}` - urlToUse = - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - Constants.DISCORD_API_URL + - '/v' + - Constants.DISCORD_API_VERSION + - urlToUse - } - - if (this.canary === true && urlToUse.startsWith('http')) { - const split = urlToUse.split('//') - urlToUse = split[0] + '//canary.' + split[1] - } - - const requestData = this.prepare(body, method) - - const response = await fetch(urlToUse, requestData) - const bucketFromHeaders = this.processHeaders(url, response.headers) - - if (response.status === 204) - return resolve( - rawResponse === true ? { response, body: null } : undefined - ) - - const json: any = await response.json() - await this.handleStatusCode(response, json, requestData, reject) - - if ( - json.retry_after !== undefined || - json.message === 'You are being rate limited.' - ) { - if (maxRetries > 10) { - throw new Error('Max RateLimit Retries hit') - } - - return { - rateLimited: json.retry_after, - before: false, - bucket: bucketFromHeaders - } - } - return resolve(rawResponse === true ? { response, body: json } : json) - } catch (error) { - return reject(error) - } - } - - this.queue({ - onComplete, - bucket, - url - }) - if (!this.processing) { - this.processing = true - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.processQueue() - } - }) - } - - /** Checks for RateLimits times and deletes if already over */ - private handleRateLimits(): void { - const now = Date.now() - this.rateLimits.forEach((value, key) => { - // Ratelimit has not ended - if (value.resetAt > now) return - // It ended, so delete - this.rateLimits.delete(key) - if (key === 'global') this.globalRateLimit = false - }) + return await this.request( + method, + url, + Object.assign( + { + data: body, + rawResponse, + route: bucket ?? undefined + }, + options + ) + ) } /** Makes a GET Request to API */ @@ -597,9 +205,18 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options?: RequestOptions ): Promise { - return await this.make('get', url, body, maxRetries, bucket, rawResponse) + return await this.make( + 'get', + url, + body, + maxRetries, + bucket, + rawResponse, + options + ) } /** Makes a POST Request to API */ @@ -608,9 +225,18 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options?: RequestOptions ): Promise { - return await this.make('post', url, body, maxRetries, bucket, rawResponse) + return await this.make( + 'post', + url, + body, + maxRetries, + bucket, + rawResponse, + options + ) } /** Makes a DELETE Request to API */ @@ -619,9 +245,18 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options?: RequestOptions ): Promise { - return await this.make('delete', url, body, maxRetries, bucket, rawResponse) + return await this.make( + 'delete', + url, + body, + maxRetries, + bucket, + rawResponse, + options + ) } /** Makes a PATCH Request to API */ @@ -630,9 +265,18 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options?: RequestOptions ): Promise { - return await this.make('patch', url, body, maxRetries, bucket, rawResponse) + return await this.make( + 'patch', + url, + body, + maxRetries, + bucket, + rawResponse, + options + ) } /** Makes a PUT Request to API */ @@ -641,8 +285,17 @@ export class RESTManager { body?: unknown, maxRetries = 0, bucket?: string | null, - rawResponse?: boolean + rawResponse?: boolean, + options?: RequestOptions ): Promise { - return await this.make('put', url, body, maxRetries, bucket, rawResponse) + return await this.make( + 'put', + url, + body, + maxRetries, + bucket, + rawResponse, + options + ) } } diff --git a/src/rest/mod.ts b/src/rest/mod.ts index e32b164..74caaa8 100644 --- a/src/rest/mod.ts +++ b/src/rest/mod.ts @@ -1,2 +1,7 @@ export * from './manager.ts' export * from './types.ts' +export * from './endpoints.ts' +export * from './error.ts' +export * from './bucket.ts' +export * from './queue.ts' +export * from './request.ts' diff --git a/src/rest/queue.ts b/src/rest/queue.ts new file mode 100644 index 0000000..5850f71 --- /dev/null +++ b/src/rest/queue.ts @@ -0,0 +1,37 @@ +// based on https://github.com/discordjs/discord.js/blob/master/src/rest/AsyncQueue.js + +export interface RequestPromise { + resolve: CallableFunction + promise: Promise +} + +export class RequestQueue { + promises: RequestPromise[] = [] + + get remaining(): number { + return this.promises.length + } + + async wait(): Promise { + const next = + this.promises.length !== 0 + ? this.promises[this.promises.length - 1].promise + : Promise.resolve() + let resolveFn: CallableFunction | undefined + const promise = new Promise((resolve) => { + resolveFn = resolve + }) + + this.promises.push({ + resolve: resolveFn!, + promise + }) + + return next + } + + shift(): void { + const deferred = this.promises.shift() + if (typeof deferred !== 'undefined') deferred.resolve() + } +} diff --git a/src/rest/request.ts b/src/rest/request.ts new file mode 100644 index 0000000..273eadb --- /dev/null +++ b/src/rest/request.ts @@ -0,0 +1,132 @@ +import type { Embed } from '../structures/embed.ts' +import type { MessageAttachment } from '../structures/message.ts' +import type { RESTManager } from './manager.ts' +import type { RequestMethods } from './types.ts' + +export interface RequestOptions { + headers?: { [name: string]: string } + query?: { [name: string]: string } + files?: MessageAttachment[] + data?: any + reason?: string + rawResponse?: boolean + route?: string +} + +export class APIRequest { + retries = 0 + route: string + + constructor( + public rest: RESTManager, + public method: RequestMethods, + public path: string, + public options: RequestOptions + ) { + this.route = options.route ?? path + if (typeof options.query === 'object') { + const entries = Object.entries(options.query) + if (entries.length > 0) { + this.path += '?' + entries.forEach((entry, i) => { + this.path += `${i === 0 ? '' : '&'}${encodeURIComponent( + entry[0] + )}=${encodeURIComponent(entry[1])}` + }) + } + } + + let _files: undefined | MessageAttachment[] + if ( + options.data?.embed?.files !== undefined && + Array.isArray(options.data?.embed?.files) + ) { + _files = [...options.data?.embed?.files] + } + if ( + options.data?.embeds !== undefined && + Array.isArray(options.data?.embeds) + ) { + const files1 = options.data?.embeds + .map((e: Embed) => e.files) + .filter((e: MessageAttachment[]) => e !== undefined) + for (const files of files1) { + for (const file of files) { + if (_files === undefined) _files = [] + _files?.push(file) + } + } + } + + if (options.data?.file !== undefined) { + if (_files === undefined) _files = [] + _files.push(options.data?.file) + } + + if ( + options.data?.files !== undefined && + Array.isArray(options.data?.files) + ) { + if (_files === undefined) _files = [] + options.data?.files.forEach((file: any) => { + _files!.push(file) + }) + } + + if (_files !== undefined && _files.length > 0) { + if (options.files === undefined) options.files = _files + else options.files = [...options.files, ..._files] + } + } + + async execute(): Promise { + let contentType: string | undefined + let body: any = this.options.data + if (this.options.files !== undefined && this.options.files.length > 0) { + contentType = undefined + const form = new FormData() + this.options.files.forEach((file, i) => + form.append(`file${i === 0 ? '' : i}`, file.blob, file.name) + ) + form.append('payload_json', JSON.stringify(body)) + body = form + } else { + contentType = 'application/json' + body = JSON.stringify(body) + } + + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort() + }, this.rest.requestTimeout) + this.rest.timers.add(timer) + + const url = this.path.startsWith('http') + ? this.path + : `${this.rest.apiURL}/v${this.rest.version}${this.path}` + + const headers: any = { + 'User-Agent': + this.rest.userAgent ?? + `DiscordBot (harmony, https://github.com/harmonyland/harmony)`, + Authorization: + this.rest.token === undefined + ? undefined + : `${this.rest.tokenType} ${this.rest.token}`.trim() + } + + if (contentType !== undefined) headers['Content-Type'] = contentType + + const init: RequestInit = { + method: this.method.toUpperCase(), + signal: controller.signal, + headers: Object.assign(headers, this.rest.headers, this.options.headers), + body + } + + return fetch(url, init).finally(() => { + clearTimeout(timer) + this.rest.timers.delete(timer) + }) + } +} diff --git a/src/utils/err_fmt.ts b/src/utils/err_fmt.ts index 6639984..5bd8672 100644 --- a/src/utils/err_fmt.ts +++ b/src/utils/err_fmt.ts @@ -17,7 +17,9 @@ export function simplifyAPIError(errors: any): SimplifiedError { } } Object.entries(errors).forEach((obj: [string, any]) => { - fmt(obj[1], obj[0]) + if (obj[0] === '_errors') { + fmt({ _errors: obj[1] }, 'Request') + } else fmt(obj[1], obj[0]) }) return res }