rework rest impl
This commit is contained in:
		
							parent
							
								
									5e65673107
								
							
						
					
					
						commit
						b479cdc743
					
				
					 10 changed files with 589 additions and 474 deletions
				
			
		|  | @ -61,10 +61,24 @@ export class ShardManager extends HarmonyEventEmitter<ShardManagerEvents> { | ||||||
|     let shardCount: number |     let shardCount: number | ||||||
|     if (this.cachedShardCount !== undefined) shardCount = this.cachedShardCount |     if (this.cachedShardCount !== undefined) shardCount = this.cachedShardCount | ||||||
|     else { |     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() |         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 |         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 |     this.cachedShardCount = shardCount | ||||||
|     return this.cachedShardCount |     return this.cachedShardCount | ||||||
|  |  | ||||||
|  | @ -266,21 +266,6 @@ export class Gateway extends HarmonyEventEmitter<GatewayTypedEvents> { | ||||||
|     if (typeof this.client.intents !== 'object') |     if (typeof this.client.intents !== 'object') | ||||||
|       throw new Error('Intents not specified') |       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) { |     if (forceNewSession === undefined || !forceNewSession) { | ||||||
|       const sessionIDCached = await this.cache.get( |       const sessionIDCached = await this.cache.get( | ||||||
|         `session_id_${this.shards?.join('-') ?? '0'}` |         `session_id_${this.shards?.join('-') ?? '0'}` | ||||||
|  |  | ||||||
|  | @ -121,6 +121,10 @@ export class ChannelsManager extends BaseManager<ChannelPayload, Channel> { | ||||||
|           : undefined |           : undefined | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (payload.content === undefined && payload.embed === undefined) { | ||||||
|  |       payload.content = '' | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     const resp = await this.client.rest.api.channels[channelID].messages.post( |     const resp = await this.client.rest.api.channels[channelID].messages.post( | ||||||
|       payload |       payload | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
							
								
								
									
										239
									
								
								src/rest/bucket.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										239
									
								
								src/rest/bucket.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<any> { | ||||||
|  |     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<void> { | ||||||
|  |     return await new Promise((resolve) => { | ||||||
|  |       this.manager.setTimeout(() => { | ||||||
|  |         this.manager.globalDelay = null | ||||||
|  |         resolve() | ||||||
|  |       }, ms) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async execute(request: APIRequest): Promise<any> { | ||||||
|  |     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 | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								src/rest/error.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/rest/error.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -1,66 +1,10 @@ | ||||||
| import { Embed } from '../structures/embed.ts' |  | ||||||
| import { MessageAttachment } from '../structures/message.ts' |  | ||||||
| import { Collection } from '../utils/collection.ts' | import { Collection } from '../utils/collection.ts' | ||||||
| import type { Client } from '../client/mod.ts' | import type { Client } from '../client/mod.ts' | ||||||
| import { simplifyAPIError } from '../utils/err_fmt.ts' | import { RequestMethods, METHODS } from './types.ts' | ||||||
| import { |  | ||||||
|   DiscordAPIErrorPayload, |  | ||||||
|   HttpResponseCode, |  | ||||||
|   RequestHeaders, |  | ||||||
|   RequestMethods, |  | ||||||
|   METHODS |  | ||||||
| } from './types.ts' |  | ||||||
| import { Constants } from '../types/constants.ts' | import { Constants } from '../types/constants.ts' | ||||||
| import { RESTEndpoints } from './endpoints.ts' | import { RESTEndpoints } from './endpoints.ts' | ||||||
| 
 | import { BucketHandler } from './bucket.ts' | ||||||
| export class DiscordAPIError extends Error { | import { APIRequest, RequestOptions } from './request.ts' | ||||||
|   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 |  | ||||||
| } |  | ||||||
| 
 | 
 | ||||||
| export type MethodFunction = ( | export type MethodFunction = ( | ||||||
|   body?: unknown, |   body?: unknown, | ||||||
|  | @ -126,6 +70,10 @@ export interface RESTOptions { | ||||||
|   userAgent?: string |   userAgent?: string | ||||||
|   /** Optional Harmony client */ |   /** Optional Harmony client */ | ||||||
|   client?: Client |   client?: Client | ||||||
|  |   /** Requests Timeout (in MS, default 30s) */ | ||||||
|  |   requestTimeout?: number | ||||||
|  |   /** Retry Limit (default 1) */ | ||||||
|  |   retryLimit?: number | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /** Token Type for REST API. */ | /** Token Type for REST API. */ | ||||||
|  | @ -140,12 +88,6 @@ export enum TokenType { | ||||||
| 
 | 
 | ||||||
| /** An easier to use interface for interacting with Discord REST API. */ | /** An easier to use interface for interacting with Discord REST API. */ | ||||||
| export class RESTManager { | export class RESTManager { | ||||||
|   queues: { [key: string]: QueuedItem[] } = {} |  | ||||||
|   rateLimits = new Collection<string, RateLimit>() |  | ||||||
|   /** 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 */ |   /** API Version being used by REST Manager */ | ||||||
|   version: number = 8 |   version: number = 8 | ||||||
|   /** |   /** | ||||||
|  | @ -173,6 +115,17 @@ export class RESTManager { | ||||||
|   /** Optional Harmony Client object */ |   /** Optional Harmony Client object */ | ||||||
|   client?: Client |   client?: Client | ||||||
|   endpoints: RESTEndpoints |   endpoints: RESTEndpoints | ||||||
|  |   requestTimeout = 30000 | ||||||
|  |   timers: Set<number> = new Set() | ||||||
|  |   apiURL = Constants.DISCORD_API_URL | ||||||
|  | 
 | ||||||
|  |   handlers = new Collection<string, BucketHandler>() | ||||||
|  |   globalLimit = Infinity | ||||||
|  |   globalRemaining = this.globalLimit | ||||||
|  |   globalReset: number | null = null | ||||||
|  |   globalDelay: number | null = null | ||||||
|  |   retryLimit = 1 | ||||||
|  |   restTimeOffset = 0 | ||||||
| 
 | 
 | ||||||
|   constructor(options?: RESTOptions) { |   constructor(options?: RESTOptions) { | ||||||
|     this.api = builder(this) |     this.api = builder(this) | ||||||
|  | @ -183,294 +136,35 @@ export class RESTManager { | ||||||
|     if (options?.userAgent !== undefined) this.userAgent = options.userAgent |     if (options?.userAgent !== undefined) this.userAgent = options.userAgent | ||||||
|     if (options?.canary !== undefined) this.canary = options.canary |     if (options?.canary !== undefined) this.canary = options.canary | ||||||
|     if (options?.client !== undefined) this.client = options.client |     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.endpoints = new RESTEndpoints(this) | ||||||
|     this.handleRateLimits() |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** Checks the queues of buckets, if empty, delete entry */ |   setTimeout(fn: (...args: any[]) => any, ms: number): number { | ||||||
|   private checkQueues(): void { |     const timer = setTimeout(async () => { | ||||||
|     Object.entries(this.queues).forEach(([key, value]) => { |       this.timers.delete(timer) | ||||||
|       if (value.length === 0) { |       await fn() | ||||||
|         // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
 |     }, ms) | ||||||
|         delete this.queues[key] |     this.timers.add(timer) | ||||||
|       } |     return timer | ||||||
|     }) |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** Adds a Request to Queue */ |   async request<T = any>( | ||||||
|   private queue(request: QueuedItem): void { |     method: RequestMethods, | ||||||
|     const route = request.url.substring( |     path: string, | ||||||
|       Number(Constants.DISCORD_API_URL.length) + 1 |     options: RequestOptions = {} | ||||||
|     ) |   ): Promise<T> { | ||||||
|     const parts = route.split('/') |     const req = new APIRequest(this, method, path, options) | ||||||
|     parts.shift() |     let handler = this.handlers.get(req.path) | ||||||
|     const [id] = parts |  | ||||||
| 
 | 
 | ||||||
|     if (this.queues[id] !== undefined) { |     if (handler === undefined) { | ||||||
|       this.queues[id].push(request) |       handler = new BucketHandler(this) | ||||||
|     } else { |       this.handlers.set(req.route, handler) | ||||||
|       this.queues[id] = [request] |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   private async processQueue(): Promise<void> { |  | ||||||
|     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 (Object.keys(this.queues).length !== 0) { |     return handler.push(req) | ||||||
|       // 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')) |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|  | @ -486,109 +180,23 @@ export class RESTManager { | ||||||
|     method: RequestMethods, |     method: RequestMethods, | ||||||
|     url: string, |     url: string, | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     _maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options: RequestOptions = {} | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     return await new Promise((resolve, reject) => { |     return await this.request( | ||||||
|       const onComplete = async (): Promise<undefined | any> => { |       method, | ||||||
|         try { |       url, | ||||||
|           const rateLimitResetIn = await this.isRateLimited(url) |       Object.assign( | ||||||
|           if (rateLimitResetIn !== false) { |         { | ||||||
|             return { |           data: body, | ||||||
|               rateLimited: rateLimitResetIn, |           rawResponse, | ||||||
|               before: true, |           route: bucket ?? undefined | ||||||
|               bucket |         }, | ||||||
|             } |         options | ||||||
|           } |       ) | ||||||
| 
 |     ) | ||||||
|           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 |  | ||||||
|     }) |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** Makes a GET Request to API */ |   /** Makes a GET Request to API */ | ||||||
|  | @ -597,9 +205,18 @@ export class RESTManager { | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options?: RequestOptions | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     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 */ |   /** Makes a POST Request to API */ | ||||||
|  | @ -608,9 +225,18 @@ export class RESTManager { | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options?: RequestOptions | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     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 */ |   /** Makes a DELETE Request to API */ | ||||||
|  | @ -619,9 +245,18 @@ export class RESTManager { | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options?: RequestOptions | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     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 */ |   /** Makes a PATCH Request to API */ | ||||||
|  | @ -630,9 +265,18 @@ export class RESTManager { | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options?: RequestOptions | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     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 */ |   /** Makes a PUT Request to API */ | ||||||
|  | @ -641,8 +285,17 @@ export class RESTManager { | ||||||
|     body?: unknown, |     body?: unknown, | ||||||
|     maxRetries = 0, |     maxRetries = 0, | ||||||
|     bucket?: string | null, |     bucket?: string | null, | ||||||
|     rawResponse?: boolean |     rawResponse?: boolean, | ||||||
|  |     options?: RequestOptions | ||||||
|   ): Promise<any> { |   ): Promise<any> { | ||||||
|     return await this.make('put', url, body, maxRetries, bucket, rawResponse) |     return await this.make( | ||||||
|  |       'put', | ||||||
|  |       url, | ||||||
|  |       body, | ||||||
|  |       maxRetries, | ||||||
|  |       bucket, | ||||||
|  |       rawResponse, | ||||||
|  |       options | ||||||
|  |     ) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,2 +1,7 @@ | ||||||
| export * from './manager.ts' | export * from './manager.ts' | ||||||
| export * from './types.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' | ||||||
|  |  | ||||||
							
								
								
									
										37
									
								
								src/rest/queue.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								src/rest/queue.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<any> | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export class RequestQueue { | ||||||
|  |   promises: RequestPromise[] = [] | ||||||
|  | 
 | ||||||
|  |   get remaining(): number { | ||||||
|  |     return this.promises.length | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async wait(): Promise<any> { | ||||||
|  |     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() | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										132
									
								
								src/rest/request.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/rest/request.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<Response> { | ||||||
|  |     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) | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | @ -17,7 +17,9 @@ export function simplifyAPIError(errors: any): SimplifiedError { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   Object.entries(errors).forEach((obj: [string, any]) => { |   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 |   return res | ||||||
| } | } | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue