"use strict"; const util = require("util"); const Base = require("../structures/Base"); const DiscordHTTPError = require("../errors/DiscordHTTPError"); const DiscordRESTError = require("../errors/DiscordRESTError"); const Endpoints = require("./Endpoints"); const HTTPS = require("https"); const MultipartData = require("../util/MultipartData"); const SequentialBucket = require("../util/SequentialBucket"); const Zlib = require("zlib"); /** * Handles API requests */ class RequestHandler { constructor(client, options) { // [DEPRECATED] Previously forceQueueing if(typeof options === "boolean") { options = { forceQueueing: options }; } this.options = options = Object.assign({ agent: client.options.agent || null, baseURL: Endpoints.BASE_URL, decodeReasons: true, disableLatencyCompensation: false, domain: "discord.com", latencyThreshold: client.options.latencyThreshold || 30000, ratelimiterOffset: client.options.ratelimiterOffset || 0, requestTimeout: client.options.requestTimeout || 15000 }, options); this._client = client; this.userAgent = `DiscordBot (https://github.com/abalabahaha/eris, ${require("../../package.json").version})`; this.ratelimits = {}; this.latencyRef = { latency: this.options.ratelimiterOffset, raw: new Array(10).fill(this.options.ratelimiterOffset), timeOffset: 0, timeOffsets: new Array(10).fill(0), lastTimeOffsetCheck: 0 }; this.globalBlock = false; this.readyQueue = []; if(this.options.forceQueueing) { this.globalBlock = true; this._client.once("shardPreReady", () => this.globalUnblock()); } } globalUnblock() { this.globalBlock = false; while(this.readyQueue.length > 0) { this.readyQueue.shift()(); } } /** * Make an API request * @arg {String} method Uppercase HTTP method * @arg {String} url URL of the endpoint * @arg {Boolean} [auth] Whether to add the Authorization header and token or not * @arg {Object} [body] Request payload * @arg {Object} [file] File object * @arg {Buffer} file.file A buffer containing file data * @arg {String} file.name What to name the file * @returns {Promise} Resolves with the returned JSON data */ request(method, url, auth, body, file, _route, short) { const route = _route || this.routefy(url, method); const _stackHolder = {}; // Preserve async stack Error.captureStackTrace(_stackHolder); return new Promise((resolve, reject) => { let attempts = 0; const actualCall = (cb) => { const headers = { "User-Agent": this.userAgent, "Accept-Encoding": "gzip,deflate" }; let data; let finalURL = url; try { if(auth) { headers.Authorization = this._client._token; } if(body && body.reason) { // Audit log reason sniping let unencodedReason = body.reason; if(this.options.decodeReasons) { try { if(unencodedReason.includes("%") && !unencodedReason.includes(" ")) { unencodedReason = decodeURIComponent(unencodedReason); } } catch(err) { this._client.emit("error", err); } } headers["X-Audit-Log-Reason"] = encodeURIComponent(unencodedReason); if((method !== "PUT" || !url.includes("/bans")) && (method !== "POST" || !url.includes("/prune"))) { delete body.reason; } else { body.reason = unencodedReason; } } if(file) { if(Array.isArray(file)) { data = new MultipartData(); headers["Content-Type"] = "multipart/form-data; boundary=" + data.boundary; file.forEach(function(f) { if(!f.file) { return; } data.attach(f.name, f.file, f.name); }); if(body) { data.attach("payload_json", body); } data = data.finish(); } else if(file.file) { data = new MultipartData(); headers["Content-Type"] = "multipart/form-data; boundary=" + data.boundary; data.attach("file", file.file, file.name); if(body) { if(method === "POST" && url.endsWith("/stickers")) { for(const key in body) { data.attach(key, body[key]); } } else { data.attach("payload_json", body); } } data = data.finish(); } else { throw new Error("Invalid file object"); } } else if(body) { if(method === "GET" || method === "DELETE") { let qs = ""; Object.keys(body).forEach(function(key) { if(body[key] != undefined) { if(Array.isArray(body[key])) { body[key].forEach(function(val) { qs += `&${encodeURIComponent(key)}=${encodeURIComponent(val)}`; }); } else { qs += `&${encodeURIComponent(key)}=${encodeURIComponent(body[key])}`; } } }); finalURL += "?" + qs.substring(1); } else { // Replacer function serializes bigints to strings, the format Discord uses data = JSON.stringify(body, (k, v) => typeof v === "bigint" ? v.toString() : v); headers["Content-Type"] = "application/json"; } } } catch(err) { cb(); reject(err); return; } let req; try { req = HTTPS.request({ method: method, host: this.options.domain, path: this.options.baseURL + finalURL, headers: headers, agent: this.options.agent }); } catch(err) { cb(); reject(err); return; } let reqError; req.once("abort", () => { cb(); reqError = reqError || new Error(`Request aborted by client on ${method} ${url}`); reqError.req = req; reject(reqError); }).once("error", (err) => { reqError = err; req.abort(); }); let latency = Date.now(); req.once("response", (resp) => { latency = Date.now() - latency; if(!this.options.disableLatencyCompensation) { this.latencyRef.raw.push(latency); this.latencyRef.latency = this.latencyRef.latency - ~~(this.latencyRef.raw.shift() / 10) + ~~(latency / 10); } if(this._client.listeners("rawREST").length) { /** * Fired when the Client's RequestHandler receives a response * @event Client#rawREST * @prop {Object} [request] The data for the request. * @prop {Boolean} request.auth True if the request required an authorization token * @prop {Object} [request.body] The request payload * @prop {Object} [request.file] The file object sent in the request * @prop {Buffer} request.file.file A buffer containing file data * @prop {String} request.file.name The name of the file * @prop {Number} request.latency The HTTP response latency * @prop {String} request.method Uppercase HTTP method * @prop {IncomingMessage} request.resp The HTTP response to the request * @prop {String} request.route The calculated ratelimiting route for the request * @prop {Boolean} request.short Whether or not the request was prioritized in its ratelimiting queue * @prop {String} request.url URL of the endpoint */ this._client.emit("rawREST", {method, url, auth, body, file, route, short, resp, latency}); } const headerNow = Date.parse(resp.headers["date"]); if(this.latencyRef.lastTimeOffsetCheck < Date.now() - 5000) { const timeOffset = headerNow + 500 - (this.latencyRef.lastTimeOffsetCheck = Date.now()); if(this.latencyRef.timeOffset - this.latencyRef.latency >= this.options.latencyThreshold && timeOffset - this.latencyRef.latency >= this.options.latencyThreshold) { this._client.emit("warn", new Error(`Your clock is ${this.latencyRef.timeOffset}ms behind Discord's server clock. Please check your connection and system time.`)); } this.latencyRef.timeOffset = this.latencyRef.timeOffset - ~~(this.latencyRef.timeOffsets.shift() / 10) + ~~(timeOffset / 10); this.latencyRef.timeOffsets.push(timeOffset); } resp.once("aborted", () => { cb(); reqError = reqError || new Error(`Request aborted by server on ${method} ${url}`); reqError.req = req; reject(reqError); }); let response = ""; let _respStream = resp; if(resp.headers["content-encoding"]) { if(resp.headers["content-encoding"].includes("gzip")) { _respStream = resp.pipe(Zlib.createGunzip()); } else if(resp.headers["content-encoding"].includes("deflate")) { _respStream = resp.pipe(Zlib.createInflate()); } } _respStream.on("data", (str) => { response += str; }).on("error", (err) => { reqError = err; req.abort(); }).once("end", () => { const now = Date.now(); if(resp.headers["x-ratelimit-limit"]) { this.ratelimits[route].limit = +resp.headers["x-ratelimit-limit"]; } if(method !== "GET" && (resp.headers["x-ratelimit-remaining"] == undefined || resp.headers["x-ratelimit-limit"] == undefined) && this.ratelimits[route].limit !== 1) { this._client.emit("debug", `Missing ratelimit headers for SequentialBucket(${this.ratelimits[route].remaining}/${this.ratelimits[route].limit}) with non-default limit\n` + `${resp.statusCode} ${resp.headers["content-type"]}: ${method} ${route} | ${resp.headers["cf-ray"]}\n` + "content-type = " + + "\n" + "x-ratelimit-remaining = " + resp.headers["x-ratelimit-remaining"] + "\n" + "x-ratelimit-limit = " + resp.headers["x-ratelimit-limit"] + "\n" + "x-ratelimit-reset = " + resp.headers["x-ratelimit-reset"] + "\n" + "x-ratelimit-global = " + resp.headers["x-ratelimit-global"]); } this.ratelimits[route].remaining = resp.headers["x-ratelimit-remaining"] === undefined ? 1 : +resp.headers["x-ratelimit-remaining"] || 0; const retryAfter = Number(resp.headers["x-ratelimit-reset-after"] || resp.headers["retry-after"]) * 1000; if(retryAfter >= 0) { if(resp.headers["x-ratelimit-global"]) { this.globalBlock = true; setTimeout(() => this.globalUnblock(), retryAfter || 1); } else { this.ratelimits[route].reset = (retryAfter || 1) + now; } } else if(resp.headers["x-ratelimit-reset"]) { let resetTime = +resp.headers["x-ratelimit-reset"] * 1000; if(route.endsWith("/reactions/:id") && (+resp.headers["x-ratelimit-reset"] * 1000 - headerNow) === 1000) { resetTime = now + 250; } this.ratelimits[route].reset = Math.max(resetTime - this.latencyRef.latency, now); } else { this.ratelimits[route].reset = now; } if(resp.statusCode !== 429) { const content = typeof body === "object" ? `${body.content} ` : ""; this._client.emit("debug", `${content}${now} ${route} ${resp.statusCode}: ${latency}ms (${this.latencyRef.latency}ms avg) | ${this.ratelimits[route].remaining}/${this.ratelimits[route].limit} left | Reset ${this.ratelimits[route].reset} (${this.ratelimits[route].reset - now}ms left)`); } if(resp.statusCode >= 300) { if(resp.statusCode === 429) { const content = typeof body === "object" ? `${body.content} ` : ""; let delay = retryAfter; if(resp.headers["x-ratelimit-scope"] === "shared") { try { delay = JSON.parse(response).retry_after * 1000; } catch(err) { reject(err); return; } } this._client.emit("debug", `${resp.headers["x-ratelimit-global"] ? "Global" : "Unexpected"} 429 (╯°□°)╯︵ ┻━┻: ${response}\n${content} ${now} ${route} ${resp.statusCode}: ${latency}ms (${this.latencyRef.latency}ms avg) | ${this.ratelimits[route].remaining}/${this.ratelimits[route].limit} left | Reset ${delay} (${this.ratelimits[route].reset - now}ms left) | Scope ${resp.headers["x-ratelimit-scope"]}`); if(delay) { setTimeout(() => { cb(); this.request(method, url, auth, body, file, route, true).then(resolve).catch(reject); }, delay); return; } else { cb(); this.request(method, url, auth, body, file, route, true).then(resolve).catch(reject); return; } } else if(resp.statusCode === 502 && ++attempts < 4) { this._client.emit("debug", "A wild 502 appeared! Thanks CloudFlare!"); setTimeout(() => { this.request(method, url, auth, body, file, route, true).then(resolve).catch(reject); }, Math.floor(Math.random() * 1900 + 100)); return cb(); } cb(); if(response.length > 0) { if(resp.headers["content-type"] === "application/json") { try { response = JSON.parse(response); } catch(err) { reject(err); return; } } } let {stack} = _stackHolder; if(stack.startsWith("Error\n")) { stack = stack.substring(6); } let err; if(response.code) { err = new DiscordRESTError(req, resp, response, stack); } else { err = new DiscordHTTPError(req, resp, response, stack); } reject(err); return; } if(response.length > 0) { if(resp.headers["content-type"] === "application/json") { try { response = JSON.parse(response); } catch(err) { cb(); reject(err); return; } } } cb(); resolve(response); }); }); req.setTimeout(this.options.requestTimeout, () => { reqError = new Error(`Request timed out (>${this.options.requestTimeout}ms) on ${method} ${url}`); req.abort(); }); if(Array.isArray(data)) { for(const chunk of data) { req.write(chunk); } req.end(); } else { req.end(data); } }; if(this.globalBlock && auth) { this.readyQueue.push(() => { if(!this.ratelimits[route]) { this.ratelimits[route] = new SequentialBucket(1, this.latencyRef); } this.ratelimits[route].queue(actualCall, short); }); } else { if(!this.ratelimits[route]) { this.ratelimits[route] = new SequentialBucket(1, this.latencyRef); } this.ratelimits[route].queue(actualCall, short); } }); } routefy(url, method) { let route = url.replace(/\/([a-z-]+)\/(?:[0-9]{17,19})/g, function(match, p) { return p === "channels" || p === "guilds" || p === "webhooks" ? match : `/${p}/:id`; }).replace(/\/reactions\/[^/]+/g, "/reactions/:id").replace(/\/reactions\/:id\/[^/]+/g, "/reactions/:id/:userID").replace(/^\/webhooks\/(\d+)\/[A-Za-z0-9-_]{64,}/, "/webhooks/$1/:token"); if(method === "DELETE" && route.endsWith("/messages/:id")) { const messageID = url.slice(url.lastIndexOf("/") + 1); const createdAt = Base.getCreatedAt(messageID); if(Date.now() - this.latencyRef.latency - createdAt >= 1000 * 60 * 60 * 24 * 14) { method += "_OLD"; } else if(Date.now() - this.latencyRef.latency - createdAt <= 1000 * 10) { method += "_NEW"; } route = method + route; } else if(method === "GET" && /\/guilds\/[0-9]+\/channels$/.test(route)) { route = "/guilds/:id/channels"; } if(method === "PUT" || method === "DELETE") { const index = route.indexOf("/reactions"); if(index !== -1) { route = "MODIFY" + route.slice(0, index + 10); } } return route; } [util.inspect.custom]() { return Base.prototype[util.inspect.custom].call(this); } toString() { return "[RequestHandler]"; } toJSON(props = []) { return Base.prototype.toJSON.call(this, [ "globalBlock", "latencyRef", "options", "ratelimits", "readyQueue", "userAgent", ...props ]); } } module.exports = RequestHandler;