seyfert
Version:
The most advanced framework for discord bots
474 lines (473 loc) • 19.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.ApiHandler = void 0;
const node_crypto_1 = require("node:crypto");
const common_1 = require("../common");
const utils_1 = require("../common/it/utils");
const bucket_1 = require("./bucket");
const Router_1 = require("./Router");
const shared_1 = require("./shared");
const utils_2 = require("./utils/utils");
class ApiHandler {
options;
globalBlock = false;
ratelimits = new Map();
readyQueue = [];
cdn = Router_1.CDNRouter.createProxy();
workerPromises;
onRatelimit;
onSuccessRequest;
onFailRequest;
constructor(options) {
this.options = {
baseUrl: 'api/v10',
domain: common_1.BASE_HOST,
type: 'Bot',
...options,
userAgent: shared_1.DefaultUserAgent,
};
if (options.debug)
this.debug = true;
const worker_threads = (0, common_1.lazyLoadPackage)('node:worker_threads');
if (options.workerProxy && !worker_threads?.parentPort && !process.send)
throw new common_1.SeyfertError('API_WORKER_PROXY_PARENT_REQUIRED', {
metadata: { detail: 'Cannot use workerProxy without a parent.' },
});
if (options.workerProxy)
this.workerPromises = new Map();
if (worker_threads?.parentPort) {
this.sendMessage = async (body) => {
worker_threads.parentPort.postMessage(body, body.requestOptions.files
?.filter(x => !['string', 'boolean', 'number'].includes(typeof x.data))
.map(x => (x.data instanceof Buffer ? (0, utils_1.toArrayBuffer)(x.data) : x.data)));
};
}
else if (process.send) {
this.sendMessage = body => {
const data = {
...body,
requestOptions: {
...body.requestOptions,
files: body.requestOptions.files?.map(file => {
if (file.data instanceof ArrayBuffer)
file.data = (0, utils_1.toBuffer)(file.data);
return file;
}),
},
};
process.send(data);
};
}
}
set debug(active) {
this.debugger = active
? new common_1.Logger({
name: '[API]',
})
: undefined;
}
get proxy() {
return (this._proxy_ ??= new Router_1.Router(this).createProxy());
}
globalUnblock() {
this.globalBlock = false;
let cb;
while ((cb = this.readyQueue.shift())) {
cb();
}
}
randomUUID() {
const uuid = (0, node_crypto_1.randomUUID)();
if (this.workerPromises.has(uuid))
return this.randomUUID();
return uuid;
}
sendMessage(_body) {
throw new common_1.SeyfertError('FUNCTION_NOT_IMPLEMENTED', { metadata: { detail: 'Function not implemented' } });
}
postMessage(body) {
this.sendMessage(body);
return new Promise((res, rej) => {
this.workerPromises.set(body.nonce, { reject: rej, resolve: res });
});
}
async notifySuccessRequest(method, url, response) {
try {
await this.onSuccessRequest?.(method, url, response);
}
catch (error) {
this.debugger?.warn('onSuccessRequest callback error', error);
}
}
async notifyFailRequest(method, url, error, statusCode) {
try {
await this.onFailRequest?.(method, url, error, statusCode);
}
catch (callbackError) {
this.debugger?.warn('onFailRequest callback error', callbackError);
}
}
async request(method, url, { auth = true, ...request } = {}) {
const originTrace = {};
Error.captureStackTrace(originTrace, this.request);
if (this.options.workerProxy) {
const nonce = this.randomUUID();
return this.postMessage({
method,
url,
type: 'WORKER_API_REQUEST',
workerId: this.workerData.workerId,
nonce,
requestOptions: { auth, ...request },
});
}
const route = request.route || this.routefy(url, method);
let attempts = 0;
const callback = async (next, resolve, reject) => {
const headers = {
'User-Agent': this.options.userAgent,
};
const { data, finalUrl } = this.parseRequest({
url,
headers,
request: { ...request, auth },
});
let response;
try {
const requestUrl = `${this.options.domain}/${this.options.baseUrl}${finalUrl}`;
this.debugger?.debug(`Sending, Method: ${method} | Url: [${finalUrl}](${route}) | Auth: ${auth}`);
response = await fetch(requestUrl, {
method,
headers,
body: data,
});
this.debugger?.debug(`Received response: ${response.statusText}(${response.status})`);
}
catch (err) {
this.debugger?.debug('Fetch error', err);
await this.notifyFailRequest(method, finalUrl, err);
next();
reject(err);
return;
}
const now = Date.now();
const headerNow = Date.parse(response.headers.get('date') ?? '');
this.setRatelimitsBucket(route, response);
this.setResetBucket(route, response, now, headerNow);
let result = await response.text();
if (response.status >= 300) {
if (response.status === 429) {
const result429 = await this.handle429(route, method, url, request, response, result, next, reject, now);
if (result429 !== false)
return resolve(result429);
await this.notifyFailRequest(method, finalUrl, result, response.status);
return this.clearResetInterval(route);
}
if ([502, 503].includes(response.status) && ++attempts < 4) {
this.clearResetInterval(route);
return this.handle50X(method, url, request, next);
}
this.clearResetInterval(route);
next();
if (result.length > 0) {
if (response.headers.get('content-type')?.includes('application/json')) {
try {
result = JSON.parse(result);
}
catch (err) {
this.debugger?.warn('SeyfertError parsing result error (', result, ')', err);
await this.notifyFailRequest(method, finalUrl, err, response.status);
reject(err);
return;
}
}
}
const parsedError = this.parseError(method, route, response, result, originTrace);
this.debugger?.warn(parsedError.message);
await this.notifyFailRequest(method, finalUrl, parsedError, response.status);
reject(parsedError);
return;
}
if (result.length > 0) {
if (response.headers.get('content-type')?.includes('application/json')) {
try {
result = JSON.parse(result);
}
catch (err) {
this.debugger?.warn('Failed parsing result (', result, ')', err);
await this.notifyFailRequest(method, finalUrl, err, response.status);
next();
reject(err);
return;
}
}
}
await this.notifySuccessRequest(method, finalUrl, response);
next();
return resolve(result || undefined);
};
return new Promise((resolve, reject) => {
if (this.globalBlock && auth) {
this.readyQueue.push(() => {
if (!this.ratelimits.has(route)) {
this.ratelimits.set(route, new bucket_1.Bucket(1));
}
this.ratelimits.get(route).push({ next: callback, resolve, reject }, request.unshift);
});
}
else {
if (!this.ratelimits.has(route)) {
this.ratelimits.set(route, new bucket_1.Bucket(1));
}
this.ratelimits.get(route).push({ next: callback, resolve, reject }, request.unshift);
}
});
}
parseError(method, route, response, result, originTrace) {
let errMessage = '';
let code;
const metadata = {
method,
route,
status: response.status,
statusText: response.statusText,
};
if (typeof result === 'object') {
if (typeof result.code !== 'undefined') {
code = String(result.code);
}
metadata.response = result;
errMessage += `${result.message ?? 'Unknown'} ${result.code ?? ''}\n[${response.status} ${response.statusText}] ${method} ${route}`;
if ('errors' in result) {
const errors = this.parseValidationError(result.errors);
errMessage += `\n${errors.join('\n') || JSON.stringify(result.errors, null, 2)}`;
}
}
else {
errMessage = `[${response.status} ${response.statusText}] ${method} ${route}`;
}
const error = new common_1.SeyfertError(`API_${response.statusText}_${code}`, {
metadata: {
...metadata,
detail: errMessage,
},
});
const originStack = originTrace?.stack;
if (originStack) {
const originLines = originStack
.split('\n')
.slice(1)
.filter(line => !line.includes('node:internal') &&
!line.includes('/src/api/api.ts') &&
!line.includes('\\src\\api\\api.ts'));
if (originLines.length) {
error.stack = `${error.name}: ${error.message}\n${originLines.join('\n')}`;
}
}
return error;
}
parseValidationError(data, path = '', errors = []) {
for (const key in data) {
if (key === '_errors') {
for (const error of data[key]) {
errors.push(`${path.slice(0, -1)} [${error.code}]: ${error.message}`);
}
}
else if (typeof data[key] === 'object') {
this.parseValidationError(data[key], `${path}${key}.`, errors);
}
}
return errors;
}
async handle50X(method, url, request, next) {
const wait = Math.floor(Math.random() * 1900 + 100);
this.debugger?.warn(`Handling a 50X status, retrying in ${wait}ms`);
next();
await (0, common_1.delay)(wait);
return this.request(method, url, {
body: request.body,
auth: request.auth,
reason: request.reason,
route: request.route,
unshift: true,
});
}
async handle429(route, method, url, request, response, result, next, reject, now) {
await this.onRatelimit?.(response, request);
const bucket = this.ratelimits.get(route);
let retryAfter;
const data = JSON.parse(result);
if (data.retry_after)
retryAfter = Math.ceil(data.retry_after * 1000);
retryAfter ??=
Number(response.headers.get('x-ratelimit-reset-after') || response.headers.get('retry-after')) * 1000;
if (Number.isNaN(retryAfter)) {
this.debugger?.warn(`${route} Could not extract retry_after from 429 response. ${result}`);
next();
reject(new common_1.SeyfertError('INVALID_RETRY_AFTER', {
metadata: {
...{
route,
method,
status: response.status,
result,
},
detail: 'Could not extract retry_after from 429 response.',
},
}));
return false;
}
if (this.debugger) {
const content = `${JSON.stringify(request)} `;
this.debugger.info(`${response.headers.get('x-ratelimit-global') ? 'Global' : 'Unexpected'} 429: ${result.slice(0, 256)}\n${content} ${now} ${route} ${response.status}: ${bucket.remaining}/${bucket.limit} left | Reset ${retryAfter} (${bucket.reset - now}ms left) | Scope ${response.headers.get('x-ratelimit-scope')}`);
}
if (retryAfter) {
await (0, common_1.delay)(retryAfter);
next();
return this.request(method, url, {
body: request.body,
auth: request.auth,
reason: request.reason,
route: request.route,
unshift: true,
});
}
next();
return this.request(method, url, {
body: request.body,
auth: request.auth,
reason: request.reason,
route: request.route,
unshift: true,
});
}
clearResetInterval(route) {
clearInterval(this.ratelimits.get(route).processingResetAfter);
this.ratelimits.get(route).processingResetAfter = undefined;
this.ratelimits.get(route).resetAfter = 0;
}
setResetBucket(route, resp, now, headerNow) {
const retryAfter = Number(resp.headers.get('x-ratelimit-reset-after') || resp.headers.get('retry-after')) * 1000;
if (retryAfter >= 0) {
if (resp.headers.get('x-ratelimit-global')) {
this.globalBlock = true;
setTimeout(() => this.globalUnblock(), retryAfter || 1);
}
else {
this.ratelimits.get(route).reset = (retryAfter || 1) + now;
}
}
else if (resp.headers.get('x-ratelimit-reset')) {
let resetTime = +resp.headers.get('x-ratelimit-reset') * 1000;
if (route.endsWith('/reactions/:id') && +resp.headers.get('x-ratelimit-reset') * 1000 - headerNow === 1000) {
resetTime = now + 250;
}
this.ratelimits.get(route).reset = Math.max(resetTime, now);
}
else {
this.ratelimits.get(route).reset = now;
}
}
setRatelimitsBucket(route, resp) {
if (resp.headers.has('x-ratelimit-limit')) {
this.ratelimits.get(route).limit = +resp.headers.get('x-ratelimit-limit');
}
this.ratelimits.get(route).remaining =
resp.headers.get('x-ratelimit-remaining') === undefined ? 1 : +resp.headers.get('x-ratelimit-remaining');
if (this.options.smartBucket) {
if (resp.headers.has('x-ratelimit-reset-after') &&
!this.ratelimits.get(route).resetAfter &&
Number(resp.headers.get('x-ratelimit-limit')) === Number(resp.headers.get('x-ratelimit-remaining')) + 1) {
this.ratelimits.get(route).resetAfter = +resp.headers.get('x-ratelimit-reset-after') * 1000;
}
if (this.ratelimits.get(route).resetAfter && !this.ratelimits.get(route).remaining) {
this.ratelimits.get(route).triggerResetAfter();
}
}
}
parseRequest(options) {
let finalUrl = options.url;
let data;
if (options.request.auth) {
options.headers.Authorization = `${this.options.type} ${options.request.token || this.options.token}`;
}
if (options.request.query) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(options.request.query)) {
if (Array.isArray(value)) {
for (const item of value) {
params.append(key, String(item));
}
}
else {
params.append(key, String(value));
}
}
finalUrl += `?${params}`;
}
if (options.request.files?.length || options.request.appendToFormData) {
const formData = new FormData();
for (const [index, file] of options.request.files?.entries() ?? []) {
const fileKey = file.key ?? `files[${index}]`;
const blobContent = (0, utils_2.isBufferLike)(file.data)
? file.data instanceof ArrayBuffer
? file.data
: (0, utils_1.toArrayBuffer)(file.data)
: `${file.data}`;
const blob = new Blob([blobContent], { type: file.contentType });
formData.append(fileKey, blob, file.filename);
}
if (options.request.body) {
if (options.request.appendToFormData) {
for (const [key, value] of Object.entries(options.request.body)) {
formData.append(key, value);
}
}
else {
formData.append('payload_json', JSON.stringify(options.request.body));
}
}
data = formData;
}
else if (options.request.body) {
options.headers['Content-Type'] = 'application/json';
data = JSON.stringify(options.request.body);
}
if (options.request.reason) {
options.headers['X-Audit-Log-Reason'] = encodeURIComponent(options.request.reason);
}
return { data, finalUrl };
}
routefy(url, method) {
if (url.startsWith('/interactions/') && url.endsWith('/callback')) {
return '/interactions/:id/:token/callback';
}
let route = url
.replace(/\/([a-z-]+)\/(?:[0-9]{17,19})/g, (match, p) => 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 = Number((0, common_1.snowflakeToTimestamp)(messageID));
if (Date.now() - createdAt >= 1000 * 60 * 60 * 24 * 14) {
method += '_OLD';
}
else if (Date.now() - 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;
}
}
exports.ApiHandler = ApiHandler;