mwn
Version:
JavaScript & TypeScript MediaWiki bot framework for Node.js
368 lines • 15.4 kB
JavaScript
"use strict";
/**
* The entry point for all API calls to wikis is the
* mwn#request() method in bot.ts. This function uses
* the Request and Response classes defined in this file.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.Response = exports.Request = void 0;
const FormData = require("form-data");
const log_1 = require("./log");
const error_1 = require("./error");
const utils_1 = require("./utils");
class Request {
constructor(bot, apiParams, requestParams) {
this.MULTIPART_THRESHOLD = 8000;
this.hasLongFields = false;
this.bot = bot;
this.apiParams = apiParams;
this.requestParams = requestParams;
}
async process() {
this.apiParams = (0, utils_1.merge)(this.bot.options.defaultParams, this.apiParams);
this.preprocessParams();
await this.fillRequestOptions();
}
getMethod() {
if (this.requestParams.method) {
return this.requestParams.method;
}
if (this.apiParams.action === 'query') {
return 'get';
}
if (this.apiParams.action === 'parse' && !this.apiParams.text) {
return 'get';
}
return 'post';
}
preprocessParams() {
let params = this.apiParams;
Object.entries(params).forEach(([key, val]) => {
if (Array.isArray(val)) {
if (!val.join('').includes('|')) {
params[key] = val.join('|');
}
else {
params[key] = '\x1f' + val.join('\x1f');
}
}
if (val === false || val === undefined) {
delete params[key];
}
else if (val === true) {
params[key] = '1'; // booleans cause error with multipart/form-data requests
}
else if (val instanceof Date) {
params[key] = val.toISOString();
}
else if (String(params[key]).length > this.MULTIPART_THRESHOLD) {
// use multipart/form-data if there are large fields, for better performance
this.hasLongFields = true;
}
});
}
async fillRequestOptions() {
let method = this.getMethod();
this.requestParams = (0, utils_1.mergeDeep1)({
url: this.bot.options.apiUrl,
method,
// retryNumber isn't actually used by the API, but this is
// included here for tracking our maxlag retry count.
retryNumber: 0,
}, this.bot.requestOptions, this.requestParams);
if (method === 'get') {
this.handleGet();
}
else {
await this.handlePost();
}
this.applyAuthentication();
}
applyAuthentication() {
let requestOptions = this.requestParams;
if (this.bot.usingOAuth2) {
// OAuth 2 authentication
requestOptions.headers['Authorization'] = `Bearer ${this.bot.options.OAuth2AccessToken}`;
}
else if (this.bot.usingOAuth) {
// OAuth 1a authentication
requestOptions.headers = {
...requestOptions.headers,
...this.makeOAuthHeader({
url: requestOptions.url,
method: requestOptions.method,
data: requestOptions.data instanceof FormData ? {} : this.apiParams,
}),
};
}
else {
// BotPassword authentication
requestOptions.withCredentials = true;
}
}
/**
* Get OAuth Authorization header
*/
makeOAuthHeader(params) {
return this.bot.oauth.toHeader(this.bot.oauth.authorize(params, {
key: this.bot.options.OAuthCredentials.accessToken,
secret: this.bot.options.OAuthCredentials.accessSecret,
}));
}
handleGet() {
// axios takes care of stringifying to URL query string
this.requestParams.params = this.apiParams;
}
async handlePost() {
// Shift the token to the end of the query string, to prevent
// incomplete data sent from being accepted meaningfully by the server
let params = this.apiParams;
if (params.token) {
let token = params.token;
delete params.token;
params.token = token;
}
if (params.action === 'query' || params.action === 'parse') {
// Per https://www.mediawiki.org/wiki/API:Etiquette, enables requests
// to be processed by closer data centres that allow only readonly operations
this.requestParams.headers['Promise-Non-Write-API-Action'] = 'true';
}
if (this.useMultipartFormData()) {
await this.handlePostMultipartFormData();
}
else {
// use application/x-www-form-urlencoded (default)
// requestOptions.data = params;
this.requestParams.data = Object.entries(params)
.map(([key, val]) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(val);
})
.join('&');
}
}
useMultipartFormData() {
let ctype = this.requestParams?.headers?.['Content-Type'];
if (ctype === 'multipart/form-data') {
return true;
}
else if (this.hasLongFields && ctype === undefined) {
return true;
}
return false;
}
async handlePostMultipartFormData() {
let params = this.apiParams, requestOptions = this.requestParams;
let form = new FormData();
for (let [key, val] of Object.entries(params)) {
if (val instanceof Object && 'stream' in val) {
// TypeScript facepalm
form.append(key, val.stream, val.name);
}
else {
form.append(key, val);
}
}
requestOptions.data = form;
requestOptions.headers = await new Promise((resolve, reject) => {
form.getLength((err, length) => {
if (err) {
reject(err);
}
resolve({
...requestOptions.headers,
...form.getHeaders(),
'Content-Length': length,
});
});
});
}
}
exports.Request = Request;
class Response {
constructor(bot, params, requestOptions) {
this.bot = bot;
this.params = params;
this.requestOptions = requestOptions;
}
async process(rawResponse) {
this.rawResponse = rawResponse;
this.response = rawResponse.data;
await this.initialCheck();
this.showWarnings();
return (await this.handleErrors()) || this.response;
}
async initialCheck() {
if (typeof this.response !== 'object') {
if (this.params.format !== 'json') {
return (0, error_1.rejectWithError)({
code: 'mwn_invalidformat',
info: 'Must use format=json!',
response: this.response,
});
}
return (0, error_1.rejectWithError)({
code: 'invalidjson',
info: 'No valid JSON response',
response: this.response,
});
}
}
showWarnings() {
if (this.response.warnings && !this.bot.options.suppressAPIWarnings) {
if (Array.isArray(this.response.warnings)) {
// new error formats
for (let { code, module, info, html, text } of this.response.warnings) {
if (code === 'deprecation-help') {
// skip
continue;
}
const msg = info || // errorformat=bc
text || // errorformat=wikitext/plaintext
html; // errorformat=html
(0, log_1.log)(`[W] Warning received from API: ${module}: ${msg}`);
}
}
else {
// legacy error format (bc)
for (let [key, info] of Object.entries(this.response.warnings)) {
// @ts-ignore
(0, log_1.log)(`[W] Warning received from API: ${key}: ${info.warnings}`);
}
}
}
}
async handleErrors() {
let error = this.response.error || // errorformat=bc (default)
this.response.errors?.[0]; // other error formats
if (error) {
error = new error_1.MwnError(error);
if (this.requestOptions.retryNumber < this.bot.options.maxRetries) {
switch (error.code) {
// This will not work if the token type to be used is defined by an
// extension, and not a part of mediawiki core
case 'badtoken':
(0, log_1.log)(`[W] Encountered badtoken error, fetching new token and retrying`);
return Promise.all([
this.bot.getTokenType(this.params.action),
this.bot.getTokens(),
]).then(([tokentype]) => {
if (!tokentype || !this.bot.state[tokentype + 'token']) {
return this.dieWithError(error);
}
this.params.token = this.bot.state[tokentype + 'token'];
return this.retry();
});
case 'readonly':
(0, log_1.log)(`[W] Encountered readonly error, waiting for ${this.bot.options.retryPause / 1000} seconds before retrying`);
return (0, utils_1.sleep)(this.bot.options.retryPause).then(() => {
return this.retry();
});
case 'maxlag':
// Handle maxlag, see https://www.mediawiki.org/wiki/Manual:Maxlag_parameter
// eslint-disable-next-line no-case-declarations
let pause = parseInt(this.rawResponse.headers['retry-after']); // axios uses lowercase headers
// retry-after appears to be usually 5 for WMF wikis
if (isNaN(pause)) {
pause = this.bot.options.retryPause / 1000;
}
(0, log_1.log)(`[W] Encountered maxlag: ${error.lag} seconds lagged. Waiting for ${pause} seconds before retrying`);
return (0, utils_1.sleep)(pause * 1000).then(() => {
return this.retry();
});
case 'assertbotfailed':
case 'assertuserfailed':
// this shouldn't have happened if we're using OAuth
if (this.bot.usingOAuth) {
return this.dieWithError(error);
}
// Possibly due to session loss: retry after logging in again
(0, log_1.log)(`[W] Received ${error.code}, attempting to log in and retry`);
return this.bot.login().then(() => {
return this.retry();
});
case 'mwoauth-invalid-authorization':
// Per https://phabricator.wikimedia.org/T106066, "Nonce already used" indicates
// an upstream memcached/redis failure which is transient
// Also handled in mwclient (https://github.com/mwclient/mwclient/pull/165/commits/d447c333e)
// and pywikibot (https://gerrit.wikimedia.org/r/c/pywikibot/core/+/289582/1/pywikibot/data/api.py)
// Some discussion in https://github.com/mwclient/mwclient/issues/164
if (error.info.includes('Nonce already used')) {
(0, log_1.log)(`[W] Retrying failed OAuth authentication in ${this.bot.options.retryPause / 1000} seconds`);
return (0, utils_1.sleep)(this.bot.options.retryPause).then(() => {
return this.retry();
});
}
else {
return this.dieWithError(error);
}
default:
return this.dieWithError(error);
}
}
else {
return this.dieWithError(error);
}
}
}
retry() {
this.requestOptions.retryNumber += 1;
return this.bot.request(this.params, this.requestOptions);
}
dieWithError(error) {
let response = this.rawResponse, requestOptions = this.requestOptions;
let errorData = Object.assign({}, error, {
// Enhance error object with additional information:
// the full API response: everything in AxiosResponse object except
// config (not needed) and request (included as errorData.request instead)
response: {
data: response.data,
status: response.status,
statusText: response.statusText,
headers: response.headers,
},
// the original request, should the client want to retry the request
request: requestOptions,
});
return (0, error_1.rejectWithError)(errorData);
}
/**
* This handles errors at the network level
* @param {Object} error
*/
handleRequestFailure(error) {
if (!error.disableRetry &&
!(error instanceof TypeError) &&
this.requestOptions.retryNumber < this.bot.options.maxRetries &&
// ENOTFOUND usually means bad apiUrl is provided, retrying is pointless and annoying
error.code !== 'ENOTFOUND' &&
(!error.response?.status ||
// Vaguely retriable error codes
[408, 409, 425, 429, 500, 502, 503, 504].includes(error.response.status))) {
// error might be transient, give it another go!
this.logError(error);
return (0, utils_1.sleep)(this.bot.options.retryPause).then(() => {
return this.retry();
});
}
error.request = this.requestOptions;
return (0, error_1.rejectWithError)(error);
}
logError(err) {
(0, log_1.log)(`[W] Retrying in ${this.bot.options.retryPause / 1000} seconds: encountered ${err}`);
const errorData = {
request: {
method: err?.request?.method,
path: err?.request?.path,
},
response: {
status: err?.response?.status,
statusText: err?.response?.statusText,
headers: err?.response?.headers,
data: err?.response?.data,
},
};
console.log(errorData);
}
}
exports.Response = Response;
//# sourceMappingURL=core.js.map