UNPKG

slash-create-modify

Version:

Create and sync Discord slash commands!

374 lines (373 loc) 19.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RequestHandler = exports.USER_AGENT = void 0; const constants_1 = require("../constants"); const https_1 = __importDefault(require("https")); const sequentialBucket_1 = require("./sequentialBucket"); const zlib_1 = __importDefault(require("zlib")); const DiscordHTTPError_1 = require("../errors/DiscordHTTPError"); const DiscordRESTError_1 = require("../errors/DiscordRESTError"); const multipartData_1 = require("./multipartData"); exports.USER_AGENT = `DiscordBot (https://github.com/Snazzah/slash-create, ${require('../../package.json').version})`; /** * The request handler for REST requests. * @private */ class RequestHandler { /** @param creator The instantiating creator. */ constructor(creator) { /** The base URL for all requests. */ this.baseURL = constants_1.API_BASE_URL; /** The user agent for all requests. */ this.userAgent = exports.USER_AGENT; /** The ratelimits per route. */ this.ratelimits = {}; /** Whether the handler is globally blocked. */ this.globalBlock = false; /** The request queue. */ this.readyQueue = []; this._creator = creator; this.requestTimeout = creator.options.requestTimeout; this.agent = creator.options.agent; this.latencyRef = { latency: 500, offset: creator.options.ratelimiterOffset, raw: new Array(10).fill(500), timeOffset: 0, timeOffsets: new Array(10).fill(0), lastTimeOffsetCheck: 0 }; } /** Unblocks the request handler. */ globalUnblock() { this.globalBlock = false; while (this.readyQueue.length > 0) { this.readyQueue.shift()(); } } /** * Make an API request * @param method Uppercase HTTP method * @param url URL of the endpoint * @param auth Whether to add the Authorization header and token or not * @param body Request payload * @param file The file(s) to send */ request(method, url, auth = true, body, file, reason, _route, short = false) { const route = _route || this.routefy(url, method); const _stackHolder = { stack: '' }; // 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', 'X-RateLimit-Precision': 'millisecond', ...(reason ? { 'X-Audit-Log-Reason': reason } : {}) }; let data; const finalURL = url; try { if (auth) { if (!this._creator.options.token) throw new Error('No token was set in the SlashCreator.'); headers.Authorization = this._creator.options.token; } if (file) { if (Array.isArray(file)) { data = new multipartData_1.MultipartData(); headers['Content-Type'] = 'multipart/form-data; boundary=' + data.boundary; file.forEach((f, i) => data.attach(`files[${i}]`, f.file, f.name)); if (body) data.attach('payload_json', body); data = data.finish(); } else if (file.file) { data = new multipartData_1.MultipartData(); headers['Content-Type'] = 'multipart/form-data; boundary=' + data.boundary; data.attach('files[0]', file.file, file.name); if (body) data.attach('payload_json', body); data = data.finish(); } else { throw new Error('Invalid file object'); } } else if (body) { if (method !== 'GET' && method !== 'DELETE') { data = JSON.stringify(body); headers['Content-Type'] = 'application/json'; } } } catch (err) { cb(); reject(err); return; } const req = https_1.default.request({ method, host: 'discord.com', path: this.baseURL + finalURL, headers: headers, agent: this.agent }); 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) => { if (this._creator.listeners('rawREST').length) this._creator.emit('rawREST', { method, url, auth, body, reason, route, short, resp }); latency = Date.now() - latency; this.latencyRef.raw.push(latency); this.latencyRef.latency = this.latencyRef.latency - ~~(this.latencyRef.raw.shift() / 10) + ~~(latency / 10); 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._creator.options.latencyThreshold && timeOffset - this.latencyRef.latency >= this._creator.options.latencyThreshold) { this._creator.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')) { // @ts-ignore _respStream = resp.pipe(zlib_1.default.createGunzip()); } else if (resp.headers['content-encoding'].includes('deflate')) { // @ts-ignore _respStream = resp.pipe(zlib_1.default.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._creator.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; let retryAfter = parseInt(resp.headers['retry-after']); // Discord breaks RFC here, using milliseconds instead of seconds (╯°□°)╯︵ ┻━┻ // This is the unofficial Discord dev-recommended way of detecting that if (retryAfter && (typeof resp.headers['via'] !== 'string' || !resp.headers['via'].includes('1.1 google'))) { retryAfter *= 1000; if (retryAfter >= 1000 * 1000) { this._creator.emit('warn', `Excessive Retry-After interval detected (Retry-After: ${resp.headers['retry-after']} * 1000, Via: ${resp.headers['via']})`); } } 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']) { if (~route.lastIndexOf('/reactions/:id') && +resp.headers['x-ratelimit-reset'] * 1000 - headerNow === 1000) { this.ratelimits[route].reset = now + 250; } else { this.ratelimits[route].reset = Math.max(+resp.headers['x-ratelimit-reset'] * 1000 - this.latencyRef.timeOffset, now); } } else { this.ratelimits[route].reset = now; } if (resp.statusCode !== 429) { this._creator.emit('debug', `${body && body.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) { this._creator.emit('debug', `${resp.headers['x-ratelimit-global'] ? 'Global' : 'Unexpected'} 429 (╯°□°)╯︵ ┻━┻: ${response}\n${body && body.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)`); // For some reason, the Retry-After header isn't in ms precision // This should hopefully fix any spam requests if (response) { try { response = JSON.parse(response); if (response.retry_after) retryAfter = response.retry_after * 1000 + 250; } catch (err) { reject(err); return; } } if (retryAfter) { setTimeout(() => { cb(); this.request(method, url, auth, body, file, reason, route, true).then(resolve).catch(reject); }, retryAfter); return; } else { cb(); this.request(method, url, auth, body, file, reason, route, true).then(resolve).catch(reject); return; } } else if (resp.statusCode === 502 && ++attempts < 4) { this._creator.emit('debug', 'A wild 502 appeared! Thanks CloudFlare!'); setTimeout(() => { this.request(method, url, auth, body, file, reason, 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_1.DiscordRESTError(req, resp, response, stack); } else { err = new DiscordHTTPError_1.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.requestTimeout, () => { reqError = new Error(`Request timed out (>${this.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.SequentialBucket(1, this.latencyRef); } this.ratelimits[route].queue(actualCall, short); }); } else { if (!this.ratelimits[route]) { this.ratelimits[route] = new sequentialBucket_1.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' || p === 'interactions' ? match : `/${p}/:id`; }) .replace(/\/reactions\/[^/]+/g, '/reactions/:id') .replace(/^\/webhooks\/(\d+)\/[A-Za-z0-9-_]{64,}/, '/webhooks/$1/:token') .replace(/\/[A-Za-z0-9-_]{64,}\/callback$/, '/:token/callback'); if (method === 'DELETE' && route.endsWith('/messages/:id')) { // Delete Messsage endpoint has its own ratelimit route = method + route; } return route; } toString() { return '[RequestHandler]'; } } exports.RequestHandler = RequestHandler;