2020-11-02 06:58:23 +00:00
|
|
|
import { delay } from '../utils/index.ts'
|
|
|
|
import * as baseEndpoints from '../consts/urlsAndVersions.ts'
|
|
|
|
import { Client } from './client.ts'
|
2020-11-06 10:42:00 +00:00
|
|
|
import { getBuildInfo } from "../utils/buildInfo.ts"
|
2020-10-23 03:19:40 +00:00
|
|
|
|
2020-10-31 11:45:33 +00:00
|
|
|
export enum HttpResponseCode {
|
|
|
|
Ok = 200,
|
|
|
|
Created = 201,
|
|
|
|
NoContent = 204,
|
|
|
|
NotModified = 304,
|
|
|
|
BadRequest = 400,
|
|
|
|
Unauthorized = 401,
|
|
|
|
Forbidden = 403,
|
|
|
|
NotFound = 404,
|
|
|
|
MethodNotAllowed = 405,
|
|
|
|
TooManyRequests = 429,
|
2020-11-01 11:22:09 +00:00
|
|
|
GatewayUnavailable = 502
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export type RequestMethods =
|
2020-11-02 06:58:23 +00:00
|
|
|
| 'get'
|
|
|
|
| 'post'
|
|
|
|
| 'put'
|
|
|
|
| 'patch'
|
|
|
|
| 'head'
|
|
|
|
| 'delete'
|
2020-10-31 11:45:33 +00:00
|
|
|
|
|
|
|
export interface QueuedRequest {
|
2020-11-02 06:58:23 +00:00
|
|
|
callback: () => Promise<
|
|
|
|
| {
|
|
|
|
rateLimited: any
|
|
|
|
beforeFetch: boolean
|
|
|
|
bucketID?: string | null
|
|
|
|
}
|
|
|
|
| undefined
|
|
|
|
>
|
|
|
|
bucketID?: string | null
|
|
|
|
url: string
|
2020-10-23 03:19:40 +00:00
|
|
|
}
|
|
|
|
|
2020-10-31 11:45:33 +00:00
|
|
|
export interface RateLimitedPath {
|
2020-11-02 06:58:23 +00:00
|
|
|
url: string
|
|
|
|
resetTimestamp: number
|
|
|
|
bucketID: string | null
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class RESTManager {
|
2020-11-02 06:58:23 +00:00
|
|
|
client: Client
|
|
|
|
globallyRateLimited: boolean = false
|
|
|
|
queueInProcess: boolean = false
|
|
|
|
pathQueues: { [key: string]: QueuedRequest[] } = {}
|
|
|
|
ratelimitedPaths = new Map<string, RateLimitedPath>()
|
|
|
|
|
|
|
|
constructor (client: Client) {
|
|
|
|
this.client = client
|
2020-11-03 07:12:22 +00:00
|
|
|
setTimeout(() => this.processRateLimitedPaths, 1000)
|
2020-11-02 06:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async processRateLimitedPaths (): Promise<void> {
|
|
|
|
const now = Date.now()
|
|
|
|
this.ratelimitedPaths.forEach((value, key) => {
|
|
|
|
if (value.resetTimestamp > now) return
|
|
|
|
this.ratelimitedPaths.delete(key)
|
|
|
|
if (key === 'global') this.globallyRateLimited = false
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
addToQueue (request: QueuedRequest): void {
|
|
|
|
const route = request.url.substring(
|
|
|
|
// eslint seriously?
|
|
|
|
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
|
|
|
baseEndpoints.DISCORD_API_URL.length + 1
|
|
|
|
)
|
|
|
|
const parts = route.split('/')
|
|
|
|
// Remove the major param
|
|
|
|
parts.shift()
|
|
|
|
const [id] = parts
|
|
|
|
|
|
|
|
if (this.pathQueues[id] !== undefined) {
|
|
|
|
this.pathQueues[id].push(request)
|
|
|
|
} else {
|
|
|
|
this.pathQueues[id] = [request]
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
async cleanupQueues (): Promise<void> {
|
|
|
|
Object.entries(this.pathQueues).forEach(([key, value]) => {
|
|
|
|
if (value.length === 0) {
|
|
|
|
// Remove it entirely
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
|
|
|
delete this.pathQueues[key]
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
async processQueue (): Promise<void> {
|
|
|
|
if (
|
|
|
|
Object.keys(this.pathQueues).length !== 0 &&
|
|
|
|
!this.globallyRateLimited
|
|
|
|
) {
|
|
|
|
await Promise.allSettled(
|
|
|
|
Object.values(this.pathQueues).map(async pathQueue => {
|
|
|
|
const request = pathQueue.shift()
|
|
|
|
if (request === undefined) return
|
|
|
|
|
|
|
|
const rateLimitedURLResetIn = await this.checkRatelimits(request.url)
|
|
|
|
|
|
|
|
if (typeof request.bucketID === 'string') {
|
|
|
|
const rateLimitResetIn = await this.checkRatelimits(
|
|
|
|
request.bucketID
|
|
|
|
)
|
|
|
|
if (rateLimitResetIn !== false) {
|
|
|
|
// This request is still rate limited read to queue
|
|
|
|
this.addToQueue(request)
|
|
|
|
} else {
|
|
|
|
// This request is not rate limited so it should be run
|
|
|
|
const result = await request.callback()
|
|
|
|
if (result?.rateLimited !== undefined) {
|
|
|
|
this.addToQueue({
|
|
|
|
...request,
|
|
|
|
bucketID: result.bucketID ?? request.bucketID
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (rateLimitedURLResetIn !== false) {
|
|
|
|
// This URL is rate limited readd to queue
|
|
|
|
this.addToQueue(request)
|
|
|
|
} else {
|
|
|
|
// This request has no bucket id so it should be processed
|
|
|
|
const result = await request.callback()
|
|
|
|
if (result?.rateLimited !== undefined) {
|
|
|
|
this.addToQueue({
|
|
|
|
...request,
|
|
|
|
bucketID: result.bucketID ?? request.bucketID
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (Object.keys(this.pathQueues).length !== 0) {
|
|
|
|
await delay(1000)
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
|
|
this.processQueue()
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
|
|
this.cleanupQueues()
|
|
|
|
} else this.queueInProcess = false
|
|
|
|
}
|
|
|
|
|
|
|
|
createRequestBody (
|
|
|
|
body: any,
|
|
|
|
method: RequestMethods
|
|
|
|
): { [key: string]: any } {
|
|
|
|
const headers: { [key: string]: string } = {
|
|
|
|
Authorization: `Bot ${this.client.token}`,
|
|
|
|
'User-Agent': `DiscordBot (discord.deno)`
|
|
|
|
}
|
|
|
|
|
2020-11-03 07:12:22 +00:00
|
|
|
if (this.client.token === undefined) delete headers.Authorization
|
2020-11-02 06:58:23 +00:00
|
|
|
|
|
|
|
if (method === 'get') body = undefined
|
|
|
|
|
|
|
|
if (body?.reason !== undefined) {
|
|
|
|
headers['X-Audit-Log-Reason'] = encodeURIComponent(body.reason)
|
|
|
|
}
|
|
|
|
|
|
|
|
if (body?.file !== undefined) {
|
|
|
|
const form = new FormData()
|
|
|
|
form.append('file', body.file.blob, body.file.name)
|
|
|
|
form.append('payload_json', JSON.stringify({ ...body, file: undefined }))
|
|
|
|
body.file = form
|
|
|
|
} else if (body !== undefined && !['get', 'delete'].includes(method)) {
|
|
|
|
headers['Content-Type'] = 'application/json'
|
|
|
|
}
|
|
|
|
|
2020-11-06 10:42:00 +00:00
|
|
|
let data: { [name: string]: any } = {
|
2020-11-02 06:58:23 +00:00
|
|
|
headers,
|
|
|
|
body: body?.file ?? JSON.stringify(body),
|
|
|
|
method: method.toUpperCase()
|
|
|
|
}
|
2020-11-06 10:42:00 +00:00
|
|
|
|
2020-11-07 02:32:14 +00:00
|
|
|
if (this.client.bot === false) {
|
2020-11-06 10:42:00 +00:00
|
|
|
// This is a selfbot. Use requests similar to Discord Client
|
|
|
|
data.headers['authorization'] = this.client.token as string
|
|
|
|
data.headers['accept-language'] = 'en-US'
|
|
|
|
data.headers['accept'] = '*/*'
|
|
|
|
data.headers['sec-fetch-dest'] = 'empty'
|
|
|
|
data.headers['sec-fetch-mode'] = 'cors'
|
|
|
|
data.headers['sec-fetch-site'] = 'same-origin'
|
|
|
|
data.headers['x-super-properties'] = btoa(JSON.stringify(getBuildInfo(this.client)))
|
|
|
|
delete data.headers['User-Agent']
|
|
|
|
delete data.headers['Authorization']
|
|
|
|
headers['credentials'] = 'include'
|
|
|
|
headers['mode'] = 'cors'
|
|
|
|
headers['referrerPolicy'] = 'no-referrer-when-downgrade'
|
|
|
|
}
|
|
|
|
|
|
|
|
return data
|
2020-11-02 06:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async checkRatelimits (url: string): Promise<number | false> {
|
|
|
|
const ratelimited = this.ratelimitedPaths.get(url)
|
|
|
|
const global = this.ratelimitedPaths.get('global')
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
|
|
if (ratelimited !== undefined && now < ratelimited.resetTimestamp) {
|
|
|
|
return ratelimited.resetTimestamp - now
|
|
|
|
}
|
|
|
|
if (global !== undefined && now < global.resetTimestamp) {
|
|
|
|
return global.resetTimestamp - now
|
|
|
|
}
|
|
|
|
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
|
|
|
|
async runMethod (
|
|
|
|
method: RequestMethods,
|
|
|
|
url: string,
|
|
|
|
body?: unknown,
|
|
|
|
retryCount = 0,
|
|
|
|
bucketID?: string | null
|
|
|
|
): Promise<any> {
|
|
|
|
const errorStack = new Error('Location In Your Files:')
|
|
|
|
Error.captureStackTrace(errorStack)
|
|
|
|
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
|
|
const callback = async (): Promise<undefined | any> => {
|
|
|
|
try {
|
|
|
|
const rateLimitResetIn = await this.checkRatelimits(url)
|
|
|
|
if (rateLimitResetIn !== false) {
|
|
|
|
return {
|
|
|
|
rateLimited: rateLimitResetIn,
|
|
|
|
beforeFetch: true,
|
|
|
|
bucketID
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const query =
|
|
|
|
method === 'get' && body !== undefined
|
|
|
|
? Object.entries(body as any)
|
|
|
|
.map(
|
|
|
|
([key, value]) =>
|
|
|
|
`${encodeURIComponent(key)}=${encodeURIComponent(
|
|
|
|
value as any
|
|
|
|
)}`
|
|
|
|
)
|
|
|
|
.join('&')
|
|
|
|
: ''
|
2020-11-06 10:42:00 +00:00
|
|
|
let urlToUse =
|
2020-11-02 06:58:23 +00:00
|
|
|
method === 'get' && query !== '' ? `${url}?${query}` : url
|
|
|
|
|
2020-11-07 02:32:14 +00:00
|
|
|
if (this.client.canary) {
|
2020-11-06 10:42:00 +00:00
|
|
|
let split = urlToUse.split('//')
|
|
|
|
urlToUse = split[0] + '//canary.' + split[1]
|
|
|
|
}
|
|
|
|
|
2020-11-03 07:12:22 +00:00
|
|
|
const requestData = this.createRequestBody(body, method)
|
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
const response = await fetch(
|
|
|
|
urlToUse,
|
2020-11-03 07:12:22 +00:00
|
|
|
requestData
|
2020-11-02 06:58:23 +00:00
|
|
|
)
|
|
|
|
const bucketIDFromHeaders = this.processHeaders(url, response.headers)
|
|
|
|
this.handleStatusCode(response, errorStack)
|
|
|
|
|
|
|
|
// Sometimes Discord returns an empty 204 response that can't be made to JSON.
|
|
|
|
if (response.status === 204) return resolve(undefined)
|
|
|
|
|
|
|
|
const json = await response.json()
|
|
|
|
if (
|
|
|
|
json.retry_after !== undefined ||
|
|
|
|
json.message === 'You are being rate limited.'
|
|
|
|
) {
|
|
|
|
if (retryCount > 10) {
|
|
|
|
throw new Error('Max RateLimit Retries hit')
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
rateLimited: json.retry_after,
|
|
|
|
beforeFetch: false,
|
|
|
|
bucketID: bucketIDFromHeaders
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return resolve(json)
|
|
|
|
} catch (error) {
|
|
|
|
return reject(error)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.addToQueue({
|
|
|
|
callback,
|
|
|
|
bucketID,
|
|
|
|
url
|
|
|
|
})
|
|
|
|
if (!this.queueInProcess) {
|
|
|
|
this.queueInProcess = true
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
|
|
this.processQueue()
|
|
|
|
}
|
|
|
|
})
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
2020-10-31 13:00:33 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
async logErrors (response: Response, errorStack?: unknown): Promise<void> {
|
|
|
|
try {
|
|
|
|
const error = await response.json()
|
|
|
|
console.error(error)
|
|
|
|
} catch {
|
|
|
|
console.error(response)
|
|
|
|
}
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
2020-10-31 13:00:33 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
handleStatusCode (
|
|
|
|
response: Response,
|
|
|
|
errorStack?: unknown
|
|
|
|
): undefined | boolean {
|
|
|
|
const status = response.status
|
|
|
|
|
|
|
|
if (
|
|
|
|
(status >= 200 && status < 400) ||
|
|
|
|
status === HttpResponseCode.TooManyRequests
|
|
|
|
) {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
|
|
this.logErrors(response, errorStack)
|
|
|
|
|
2020-11-07 02:32:14 +00:00
|
|
|
if (status === HttpResponseCode.Unauthorized) throw new Error("Request was not successful. Invalid Token.")
|
2020-11-03 07:12:22 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
switch (status) {
|
|
|
|
case HttpResponseCode.BadRequest:
|
|
|
|
case HttpResponseCode.Unauthorized:
|
|
|
|
case HttpResponseCode.Forbidden:
|
|
|
|
case HttpResponseCode.NotFound:
|
|
|
|
case HttpResponseCode.MethodNotAllowed:
|
2020-11-03 07:12:22 +00:00
|
|
|
throw new Error('Request Client Error.')
|
2020-11-02 06:58:23 +00:00
|
|
|
case HttpResponseCode.GatewayUnavailable:
|
2020-11-03 07:12:22 +00:00
|
|
|
throw new Error('Request Server Error.')
|
2020-11-02 06:58:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// left are all unknown
|
|
|
|
throw new Error('Request Unknown Error')
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
2020-10-31 13:00:33 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
processHeaders (url: string, headers: Headers): string | null | undefined {
|
|
|
|
let ratelimited = false
|
|
|
|
|
|
|
|
// Get all useful headers
|
|
|
|
const remaining = headers.get('x-ratelimit-remaining')
|
|
|
|
const resetTimestamp = headers.get('x-ratelimit-reset')
|
|
|
|
const retryAfter = headers.get('retry-after')
|
|
|
|
const global = headers.get('x-ratelimit-global')
|
|
|
|
const bucketID = headers.get('x-ratelimit-bucket')
|
|
|
|
|
|
|
|
// If there is no remaining rate limit for this endpoint, we save it in cache
|
|
|
|
if (remaining !== null && remaining === '0') {
|
|
|
|
ratelimited = true
|
|
|
|
|
|
|
|
this.ratelimitedPaths.set(url, {
|
|
|
|
url,
|
|
|
|
resetTimestamp: Number(resetTimestamp) * 1000,
|
|
|
|
bucketID
|
|
|
|
})
|
|
|
|
|
|
|
|
if (bucketID !== null) {
|
|
|
|
this.ratelimitedPaths.set(bucketID, {
|
|
|
|
url,
|
|
|
|
resetTimestamp: Number(resetTimestamp) * 1000,
|
|
|
|
bucketID
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// If there is no remaining global limit, we save it in cache
|
|
|
|
if (global !== null) {
|
|
|
|
const reset = Date.now() + Number(retryAfter)
|
|
|
|
this.globallyRateLimited = true
|
|
|
|
ratelimited = true
|
|
|
|
|
|
|
|
this.ratelimitedPaths.set('global', {
|
|
|
|
url: 'global',
|
|
|
|
resetTimestamp: reset,
|
|
|
|
bucketID
|
|
|
|
})
|
|
|
|
|
|
|
|
if (bucketID !== null) {
|
|
|
|
this.ratelimitedPaths.set(bucketID, {
|
|
|
|
url: 'global',
|
|
|
|
resetTimestamp: reset,
|
|
|
|
bucketID
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return ratelimited ? bucketID : undefined
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
2020-10-31 13:00:33 +00:00
|
|
|
|
2020-11-02 06:58:23 +00:00
|
|
|
async get (url: string, body?: unknown): Promise<any> {
|
|
|
|
return await this.runMethod('get', url, body)
|
2020-10-31 11:45:33 +00:00
|
|
|
}
|
2020-11-02 06:58:23 +00:00
|
|
|
|
|
|
|
async post (url: string, body?: unknown): Promise<any> {
|
|
|
|
return await this.runMethod('post', url, body)
|
|
|
|
}
|
|
|
|
|
|
|
|
async delete (url: string, body?: unknown): Promise<any> {
|
|
|
|
return await this.runMethod('delete', url, body)
|
|
|
|
}
|
|
|
|
|
|
|
|
async patch (url: string, body?: unknown): Promise<any> {
|
|
|
|
return await this.runMethod('patch', url, body)
|
|
|
|
}
|
|
|
|
|
|
|
|
async put (url: string, body?: unknown): Promise<any> {
|
|
|
|
return await this.runMethod('put', url, body)
|
|
|
|
}
|
|
|
|
}
|