muskytape
Version:
Framework não oficial do Discord.js
435 lines (390 loc) • 14 kB
JavaScript
const { escapeMarkdown } = require('discord.js');
const { oneLine, stripIndents } = require('common-tags');
const ArgumentUnionType = require('../types/union');
/** A fancy argument */
class Argument {
/**
* @typedef {Object} ArgumentInfo
* @property {string} key - Key for the argument
* @property {string} [label=key] - Label for the argument
* @property {string} prompt - First prompt for the argument when it wasn't specified
* @property {string} [error] - Predefined error message to output for the argument when it isn't valid
* @property {string} [type] - Type of the argument (must be the ID of one of the registered argument types
* or multiple IDs in order of priority separated by `|` for a union type - see
* {@link CommandoRegistry#registerDefaultTypes} for the built-in types)
* @property {number} [max] - If type is `integer` or `float`, this is the maximum value of the number.
* If type is `string`, this is the maximum length of the string.
* @property {number} [min] - If type is `integer` or `float`, this is the minimum value of the number.
* If type is `string`, this is the minimum length of the string.
* @property {ArgumentDefault} [default] - Default value for the argument (makes the arg optional - cannot be `null`)
* @property {string[]} [oneOf] - An array of values that are allowed to be used
* @property {boolean} [infinite=false] - Whether the argument accepts infinite values
* @property {Function} [validate] - Validator function for the argument (see {@link ArgumentType#validate})
* @property {Function} [parse] - Parser function for the argument (see {@link ArgumentType#parse})
* @property {Function} [isEmpty] - Empty checker for the argument (see {@link ArgumentType#isEmpty})
* @property {number} [wait=30] - How long to wait for input (in seconds)
*/
/**
* Either a value or a function that returns a value. The function is passed the CommandoMessage and the Argument.
* @typedef {*|Function} ArgumentDefault
*/
/**
* @param {CommandoClient} client - Client the argument is for
* @param {ArgumentInfo} info - Information for the command argument
*/
constructor(client, info) {
this.constructor.validateInfo(client, info);
/**
* Key for the argument
* @type {string}
*/
this.key = info.key;
/**
* Label for the argument
* @type {string}
*/
this.label = info.label || info.key;
/**
* Question prompt for the argument
* @type {string}
*/
this.prompt = info.prompt;
/**
* Error message for when a value is invalid
* @type {?string}
*/
this.error = info.error || null;
/**
* Type of the argument
* @type {?ArgumentType}
*/
this.type = this.constructor.determineType(client, info.type);
/**
* If type is `integer` or `float`, this is the maximum value of the number.
* If type is `string`, this is the maximum length of the string.
* @type {?number}
*/
this.max = typeof info.max !== 'undefined' ? info.max : null;
/**
* If type is `integer` or `float`, this is the minimum value of the number.
* If type is `string`, this is the minimum length of the string.
* @type {?number}
*/
this.min = typeof info.min !== 'undefined' ? info.min : null;
/**
* The default value for the argument
* @type {?ArgumentDefault}
*/
this.default = typeof info.default !== 'undefined' ? info.default : null;
/**
* Values the user can choose from
* If type is `string`, this will be case-insensitive
* If type is `channel`, `member`, `role`, or `user`, this will be the IDs.
* @type {?string[]}
*/
this.oneOf = typeof info.oneOf !== 'undefined' ? info.oneOf : null;
/**
* Whether the argument accepts an infinite number of values
* @type {boolean}
*/
this.infinite = Boolean(info.infinite);
/**
* Validator function for validating a value for the argument
* @type {?Function}
* @see {@link ArgumentType#validate}
*/
this.validator = info.validate || null;
/**
* Parser function for parsing a value for the argument
* @type {?Function}
* @see {@link ArgumentType#parse}
*/
this.parser = info.parse || null;
/**
* Function to check whether a raw value is considered empty
* @type {?Function}
* @see {@link ArgumentType#isEmpty}
*/
this.emptyChecker = info.isEmpty || null;
/**
* How long to wait for input (in seconds)
* @type {number}
*/
this.wait = typeof info.wait !== 'undefined' ? info.wait : 30;
}
/**
* Result object from obtaining a single {@link Argument}'s value(s)
* @typedef {Object} ArgumentResult
* @property {?*|?Array<*>} value - Final value(s) for the argument
* @property {?string} cancelled - One of:
* - `user` (user cancelled)
* - `time` (wait time exceeded)
* - `promptLimit` (prompt limit exceeded)
* @property {Message[]} prompts - All messages that were sent to prompt the user
* @property {Message[]} answers - All of the user's messages that answered a prompt
*/
/**
* Prompts the user and obtains the value for the argument
* @param {CommandoMessage} msg - Message that triggered the command
* @param {string} [val] - Pre-provided value for the argument
* @param {number} [promptLimit=Infinity] - Maximum number of times to prompt for the argument
* @return {Promise<ArgumentResult>}
*/
async obtain(msg, val, promptLimit = Infinity) {
let empty = this.isEmpty(val, msg);
if(empty && this.default !== null) {
return {
value: typeof this.default === 'function' ? await this.default(msg, this) : this.default,
cancelled: null,
prompts: [],
answers: []
};
}
if(this.infinite) return this.obtainInfinite(msg, val, promptLimit);
const wait = this.wait > 0 && this.wait !== Infinity ? this.wait * 1000 : undefined;
const prompts = [];
const answers = [];
let valid = !empty ? await this.validate(val, msg) : false;
while(!valid || typeof valid === 'string') {
/* eslint-disable no-await-in-loop */
if(prompts.length >= promptLimit) {
return {
value: null,
cancelled: 'promptLimit',
prompts,
answers
};
}
// Prompt the user for a new value
prompts.push(await msg.reply(stripIndents`
${empty ? this.prompt : valid ? valid : `Você forneceu um ${this.label} inválido. Por favor, tente novamente.`}
${oneLine`
${wait ? `` : ''}
`}
`));
// Get the user's response
const responses = await msg.channel.awaitMessages(msg2 => msg2.author.id === msg.author.id, {
max: 1,
time: wait
});
// Make sure they actually answered
if(responses && responses.size === 1) {
answers.push(responses.first());
val = answers[answers.length - 1].content;
} else {
return {
value: null,
cancelled: 'time',
prompts,
answers
};
}
// See if they want to cancel
if(val.toLowerCase() === 'cancel') {
return {
value: null,
cancelled: 'user',
prompts,
answers
};
}
empty = this.isEmpty(val, msg);
valid = await this.validate(val, msg);
/* eslint-enable no-await-in-loop */
}
return {
value: await this.parse(val, msg),
cancelled: null,
prompts,
answers
};
}
/**
* Prompts the user and obtains multiple values for the argument
* @param {CommandoMessage} msg - Message that triggered the command
* @param {string[]} [vals] - Pre-provided values for the argument
* @param {number} [promptLimit=Infinity] - Maximum number of times to prompt for the argument
* @return {Promise<ArgumentResult>}
* @private
*/
async obtainInfinite(msg, vals, promptLimit = Infinity) { // eslint-disable-line complexity
const wait = this.wait > 0 && this.wait !== Infinity ? this.wait * 1000 : undefined;
const results = [];
const prompts = [];
const answers = [];
let currentVal = 0;
while(true) { // eslint-disable-line no-constant-condition
/* eslint-disable no-await-in-loop */
let val = vals && vals[currentVal] ? vals[currentVal] : null;
let valid = val ? await this.validate(val, msg) : false;
let attempts = 0;
while(!valid || typeof valid === 'string') {
attempts++;
if(attempts > promptLimit) {
return {
value: null,
cancelled: 'promptLimit',
prompts,
answers
};
}
// Prompt the user for a new value
if(val) {
const escaped = escapeMarkdown(val).replace(/@/g, '@\u200b');
prompts.push(await msg.reply(stripIndents`
${valid ? valid : oneLine`
You provided an invalid ${this.label},
"${escaped.length < 1850 ? escaped : '[too long to show]'}".
Please try again.
`}
${oneLine`
Respond with \`cancel\` to cancel the command, or \`finish\` to finish entry up to this point.
${wait ? `The command will automatically be cancelled in ${this.wait} seconds.` : ''}
`}
`));
} else if(results.length === 0) {
prompts.push(await msg.reply(stripIndents`
${this.prompt}
${oneLine`
Respond with \`cancel\` to cancel the command, or \`finish\` to finish entry.
${wait ? `The command will automatically be cancelled in ${this.wait} seconds, unless you respond.` : ''}
`}
`));
}
// Get the user's response
const responses = await msg.channel.awaitMessages(msg2 => msg2.author.id === msg.author.id, {
max: 1,
time: wait
});
// Make sure they actually answered
if(responses && responses.size === 1) {
answers.push(responses.first());
val = answers[answers.length - 1].content;
} else {
return {
value: null,
cancelled: 'time',
prompts,
answers
};
}
// See if they want to finish or cancel
const lc = val.toLowerCase();
if(lc === 'finish') {
return {
value: results.length > 0 ? results : null,
cancelled: this.default ? null : results.length > 0 ? null : 'user',
prompts,
answers
};
}
if(lc === 'cancel') {
return {
value: null,
cancelled: 'user',
prompts,
answers
};
}
valid = await this.validate(val, msg);
}
results.push(await this.parse(val, msg));
if(vals) {
currentVal++;
if(currentVal === vals.length) {
return {
value: results,
cancelled: null,
prompts,
answers
};
}
}
/* eslint-enable no-await-in-loop */
}
}
/**
* Checks if a value is valid for the argument
* @param {string} val - Value to check
* @param {CommandoMessage} msg - Message that triggered the command
* @return {boolean|string|Promise<boolean|string>}
*/
validate(val, msg) {
const valid = this.validator ? this.validator(val, msg, this) : this.type.validate(val, msg, this);
if(!valid || typeof valid === 'string') return this.error || valid;
if(valid instanceof Promise) return valid.then(vld => !vld || typeof vld === 'string' ? this.error || vld : vld);
return valid;
}
/**
* Parses a value string into a proper value for the argument
* @param {string} val - Value to parse
* @param {CommandoMessage} msg - Message that triggered the command
* @return {*|Promise<*>}
*/
parse(val, msg) {
if(this.parser) return this.parser(val, msg, this);
return this.type.parse(val, msg, this);
}
/**
* Checks whether a value for the argument is considered to be empty
* @param {string} val - Value to check for emptiness
* @param {CommandoMessage} msg - Message that triggered the command
* @return {boolean}
*/
isEmpty(val, msg) {
if(this.emptyChecker) return this.emptyChecker(val, msg, this);
if(this.type) return this.type.isEmpty(val, msg, this);
if(Array.isArray(val)) return val.length === 0;
return !val;
}
/**
* Validates the constructor parameters
* @param {CommandoClient} client - Client to validate
* @param {ArgumentInfo} info - Info to validate
* @private
*/
static validateInfo(client, info) { // eslint-disable-line complexity
if(!client) throw new Error('The argument client must be specified.');
if(typeof info !== 'object') throw new TypeError('Argument info must be an Object.');
if(typeof info.key !== 'string') throw new TypeError('Argument key must be a string.');
if(info.label && typeof info.label !== 'string') throw new TypeError('Argument label must be a string.');
if(typeof info.prompt !== 'string') throw new TypeError('Argument prompt must be a string.');
if(info.error && typeof info.error !== 'string') throw new TypeError('Argument error must be a string.');
if(info.type && typeof info.type !== 'string') throw new TypeError('Argument type must be a string.');
if(info.type && !info.type.includes('|') && !client.registry.types.has(info.type)) {
throw new RangeError(`Argument type "${info.type}" isn't registered.`);
}
if(!info.type && !info.validate) {
throw new Error('Argument must have either "type" or "validate" specified.');
}
if(info.validate && typeof info.validate !== 'function') {
throw new TypeError('Argument validate must be a function.');
}
if(info.parse && typeof info.parse !== 'function') {
throw new TypeError('Argument parse must be a function.');
}
if(!info.type && (!info.validate || !info.parse)) {
throw new Error('Argument must have both validate and parse since it doesn\'t have a type.');
}
if(typeof info.wait !== 'undefined' && (typeof info.wait !== 'number' || Number.isNaN(info.wait))) {
throw new TypeError('Argument wait must be a number.');
}
}
/**
* Gets the argument type to use from an ID
* @param {CommandoClient} client - Client to use the registry of
* @param {string} id - ID of the type to use
* @returns {?ArgumentType}
* @private
*/
static determineType(client, id) {
if(!id) return null;
if(!id.includes('|')) return client.registry.types.get(id);
let type = client.registry.types.get(id);
if(type) return type;
type = new ArgumentUnionType(client, id);
client.registry.registerType(type);
return type;
}
}
module.exports = Argument;