lots of important stuff (#1883)

* lots of important stuff

* Update Constants.js
This commit is contained in:
Gus Caplan
2017-09-04 10:49:44 -05:00
committed by Crawl
parent 3a503ef56e
commit 18e3801bb7
15 changed files with 212 additions and 253 deletions

40
src/rest/APIRequest.js Normal file
View File

@@ -0,0 +1,40 @@
const querystring = require('querystring');
const snekfetch = require('snekfetch');
class APIRequest {
constructor(rest, method, path, options) {
this.rest = rest;
this.client = rest.client;
this.method = method;
this.path = path.toString();
this.route = options.route;
this.options = options;
}
gen() {
const API = this.options.versioned === false ? this.client.options.http.api :
`${this.client.options.http.api}/v${this.client.options.http.version}`;
if (this.options.query) {
const queryString = (querystring.stringify(this.options.query).match(/[^=&?]+=[^=&?]+/g) || []).join('&');
this.path += `?${queryString}`;
}
const request = snekfetch[this.method](`${API}${this.path}`);
if (this.options.auth !== false) request.set('Authorization', this.rest.getAuth());
if (this.options.reason) request.set('X-Audit-Log-Reason', encodeURIComponent(this.options.reason));
if (!this.rest.client.browser) request.set('User-Agent', this.rest.userAgentManager.userAgent);
if (this.options.headers) request.set(this.options.headers);
if (this.options.files) {
for (const file of this.options.files) if (file && file.file) request.attach(file.name, file.file, file.name);
if (typeof this.options.data !== 'undefined') request.attach('payload_json', JSON.stringify(this.options.data));
} else if (typeof this.options.data !== 'undefined') {
request.send(this.options.data);
}
return request;
}
}
module.exports = APIRequest;

35
src/rest/APIRouter.js Normal file
View File

@@ -0,0 +1,35 @@
const util = require('util');
const noop = () => {}; // eslint-disable-line no-empty-function
const methods = ['get', 'post', 'delete', 'patch', 'put'];
const reflectors = [
'toString', 'valueOf', 'inspect', 'constructor',
Symbol.toPrimitive, util.inspect.custom,
];
function buildRoute(manager) {
const route = [''];
const handler = {
get(target, name) {
if (reflectors.includes(name)) return () => route.join('/');
if (methods.includes(name)) {
return options => manager.request(name, route.join('/'), Object.assign({
versioned: manager.versioned,
route: route.map((r, i) => {
if (/\d{16,19}/g.test(r)) return /channels|guilds/.test(route[i - 1]) ? r : ':id';
return r;
}).join('/'),
}, options));
}
route.push(name);
return new Proxy(noop, handler);
},
apply(target, _, args) {
route.push(...args.filter(x => x != null)); // eslint-disable-line eqeqeq
return new Proxy(noop, handler);
},
};
return new Proxy(noop, handler);
}
module.exports = buildRoute;

View File

@@ -0,0 +1,54 @@
/**
* Represents an error from the Discord API.
* @extends Error
*/
class DiscordAPIError extends Error {
constructor(path, error) {
super();
const flattened = this.constructor.flattenErrors(error.errors || error).join('\n');
this.name = 'DiscordAPIError';
this.message = error.message && flattened ? `${error.message}\n${flattened}` : error.message || flattened;
/**
* The path of the request relative to the HTTP endpoint
* @type {string}
*/
this.path = path;
/**
* HTTP error code returned by Discord
* @type {number}
*/
this.code = error.code;
}
/**
* Flattens an errors object returned from the API into an array.
* @param {Object} obj Discord errors object
* @param {string} [key] Used internally to determine key names of nested fields
* @returns {string[]}
* @private
*/
static flattenErrors(obj, key = '') {
let messages = [];
for (const [k, v] of Object.entries(obj)) {
if (k === 'message') continue;
const newKey = key ? isNaN(k) ? `${key}.${k}` : `${key}[${k}]` : k;
if (v._errors) {
messages.push(`${newKey}: ${v._errors.map(e => e.message).join(' ')}`);
} else if (v.code || v.message) {
messages.push(`${v.code ? `${v.code}: ` : ''}${v.message}`.trim());
} else if (typeof v === 'string') {
messages.push(v);
} else {
messages = messages.concat(this.flattenErrors(v, newKey));
}
}
return messages;
}
}
module.exports = DiscordAPIError;

73
src/rest/RESTManager.js Normal file
View File

@@ -0,0 +1,73 @@
const UserAgentManager = require('./UserAgentManager');
const handlers = require('./handlers');
const APIRequest = require('./APIRequest');
const routeBuilder = require('./APIRouter');
const { Error } = require('../errors');
const Constants = require('../util/Constants');
class RESTManager {
constructor(client, tokenPrefix = 'Bot') {
this.client = client;
this.handlers = {};
this.userAgentManager = new UserAgentManager(this);
this.rateLimitedEndpoints = {};
this.globallyRateLimited = false;
this.tokenPrefix = tokenPrefix;
this.versioned = true;
}
get api() {
return routeBuilder(this);
}
getAuth() {
const token = this.client.token || this.client.accessToken;
const prefixed = !!this.client.application || (this.client.user && this.client.user.bot);
if (token && prefixed) return `${this.tokenPrefix} ${token}`;
else if (token) return token;
throw new Error('TOKEN_MISSING');
}
get cdn() {
return Constants.Endpoints.CDN(this.client.options.http.cdn);
}
destroy() {
for (const handler of Object.values(this.handlers)) {
if (handler.destroy) handler.destroy();
}
}
push(handler, apiRequest) {
return new Promise((resolve, reject) => {
handler.push({
request: apiRequest,
resolve,
reject,
});
});
}
getRequestHandler() {
const method = this.client.options.apiRequestMethod;
if (typeof method === 'function') return method;
const handler = handlers[method];
if (!handler) throw new Error('RATELIMIT_INVALID_METHOD');
return handler;
}
request(method, url, options = {}) {
const apiRequest = new APIRequest(this, method, url, options);
if (!this.handlers[apiRequest.route]) {
this.handlers[apiRequest.route] = new handlers.RequestHandler(this, this.getRequestHandler());
}
return this.push(this.handlers[apiRequest.route], apiRequest);
}
set endpoint(endpoint) {
this.client.options.http.api = endpoint;
}
}
module.exports = RESTManager;

View File

@@ -0,0 +1,25 @@
const Constants = require('../util/Constants');
class UserAgentManager {
constructor() {
this.build(this.constructor.DEFAULT);
}
set({ url, version } = {}) {
this.build({
url: url || this.constructor.DFEAULT.url,
version: version || this.constructor.DEFAULT.version,
});
}
build(ua) {
this.userAgent = `DiscordBot (${ua.url}, ${ua.version}) Node.js/${process.version}`;
}
}
UserAgentManager.DEFAULT = {
url: Constants.Package.homepage.split('#')[0],
version: Constants.Package.version,
};
module.exports = UserAgentManager;

View File

@@ -0,0 +1,70 @@
const DiscordAPIError = require('../DiscordAPIError');
class RequestHandler {
constructor(manager, handler) {
this.manager = manager;
this.client = this.manager.client;
this.handle = handler.bind(this);
this.limit = Infinity;
this.resetTime = null;
this.remaining = 1;
this.timeDifference = 0;
this.queue = [];
}
get limited() {
return this.manager.globallyRateLimited || this.remaining <= 0;
}
set globallyLimited(limited) {
this.manager.globallyRateLimited = limited;
}
push(request) {
this.queue.push(request);
this.handle();
}
execute(item) {
return new Promise((resolve, reject) => {
const finish = timeout => {
// eslint-disable-next-line prefer-promise-reject-errors
if (timeout || this.limited) reject({ timeout, limited: this.limited });
else resolve();
};
item.request.gen().end((err, res) => {
if (res && res.headers) {
if (res.headers['x-ratelimit-global']) this.globallyLimited = true;
this.limit = Number(res.headers['x-ratelimit-limit']);
this.resetTime = Number(res.headers['x-ratelimit-reset']) * 1000;
this.remaining = Number(res.headers['x-ratelimit-remaining']);
this.timeDifference = Date.now() - new Date(res.headers.date).getTime();
}
if (err) {
if (err.status === 429) {
this.queue.unshift(item);
finish(Number(res.headers['retry-after']) + this.client.options.restTimeOffset);
} else if (err.status >= 500 && err.status < 600) {
this.queue.unshift(item);
finish(1e3 + this.client.options.restTimeOffset);
} else {
item.reject(err.status >= 400 && err.status < 500 ? new DiscordAPIError(res.request.path, res.body) : err);
finish();
}
} else {
const data = res && res.body ? res.body : {};
item.resolve(data);
finish();
}
});
});
}
reset() {
this.globallyLimited = false;
this.remaining = 1;
}
}
module.exports = RequestHandler;

View File

@@ -0,0 +1,13 @@
module.exports = function burst() {
if (this.limited || this.queue.length === 0) return;
this.execute(this.queue.shift())
.then(this.handle.bind(this))
.catch(({ timeout }) => {
this.client.setTimeout(() => {
this.reset();
this.handle();
}, timeout || (this.resetTime - Date.now() + this.timeDifference + this.client.options.restTimeOffset));
});
this.remaining--;
this.handle();
};

View File

@@ -0,0 +1,5 @@
module.exports = {
sequential: require('./sequential'),
burst: require('./burst'),
RequestHandler: require('./RequestHandler'),
};

View File

@@ -0,0 +1,16 @@
module.exports = function sequential() {
if (this.busy || this.limited || this.queue.length === 0) return;
this.busy = true;
this.execute(this.queue.shift())
.then(() => {
this.busy = false;
this.handle();
})
.catch(({ timeout }) => {
this.client.setTimeout(() => {
this.reset();
this.busy = false;
this.handle();
}, timeout || (this.resetTime - Date.now() + this.timeDifference + this.client.options.restTimeOffset));
});
};