src/bot/util.js
'use babel';
'use strict';
/** Contains general utility methods */
export default class BotUtil {
/**
* @param {Client} client - The client to use
* @param {SettingStorage} settings - The setting storage to use
* @param {BotConfig} config - The bot config to use
*/
constructor(client, settings, config) {
if(!client || !settings || !config) throw new Error('A client, settings, and config must be specified.');
/** @type {Client} */
this.client = client;
/** @type {SettingStorage} */
this.settings = settings;
/** @type {BotConfig} */
this.config = config;
/**
* @type {PatternConstants}
* @see {@link BotUtil.patterns}
*/
this.patterns = patterns;
}
/**
* Build a command usage string
* @param {string} command - The short command string (ex. "roll d20")
* @param {Guild|string} [guild] - The guild or guild ID to use the prefix of
* @param {boolean} [onlyMention=false] - Whether or not the usage string should only show the mention form
* @return {string} The command usage string
* @see {@link BotUtil.usage}
*/
usage(command, guild = null, onlyMention = false) {
return this.constructor.usage(this.client, this.settings, this.config, command, guild, onlyMention);
}
/**
* Build a command usage string
* @param {Client} client - The client to use
* @param {SettingStorage} settings - The setting storage to use
* @param {BotConfig} config - The bot config to use
* @param {string} command - The short command string (ex. "roll d20")
* @param {Guild|string} [guild] - The guild or guild ID to use the prefix of
* @param {boolean} [onlyMention=false] - Whether or not the usage string should only show the mention form
* @return {string} The command usage string
* @see {@link BotUtil#usage}
*/
static usage(client, settings, config, command, guild = null, onlyMention = false) {
const nbcmd = this.nbsp(command);
if(!guild && !onlyMention) return `\`${nbcmd}\``;
let prefixAddon;
if(!onlyMention) {
let prefix = this.nbsp(settings.getValue(guild, 'command-prefix', config.values.commandPrefix));
if(prefix.length > 1 && !prefix.endsWith('\xa0')) prefix += '\xa0';
prefixAddon = prefix ? `\`${prefix}${nbcmd}\` or ` : '';
}
return `${prefixAddon || ''}\`@${this.nbsp(client.user.username)}#${client.user.discriminator}\xa0${nbcmd}\``;
}
/**
* Build a disambiguation list - useful for telling a user to be more specific when finding partial matches from a command
* @param {Object[]} items - An array of items to make the disambiguation list for
* @param {string} label - The text to refer to the items as (ex. "characters")
* @param {string} [property=name] - The property on items to display in the list
* @return {string} The disambiguation list
* @see {@link BotUtil.disambiguation}
*/
disambiguation(items, label, property = 'name') {
return this.constructor.disambiguation(items, label, property);
}
/**
* Build a disambiguation list - useful for telling a user to be more specific when finding partial matches from a command
* @param {Object[]} items - An array of items to make the disambiguation list for
* @param {string} label - The text to refer to the items as (ex. "characters")
* @param {string} [property=name] - The property on items to display in the list
* @return {string} The disambiguation list
* @see {@link BotUtil#disambiguation}
*/
static disambiguation(items, label, property = 'name') {
const itemList = items.map(item => `"${this.nbsp(property ? item[property] : item)}"`).join(', ');
return `Multiple ${label} found, please be more specific: ${itemList}`;
}
/**
* Paginate an array of items
* @param {Object[]} items - An array of items to paginate
* @param {number} [page=1] - The page to select
* @param {number} [pageLength=10] - The number of items per page
* @return {Object} The resulting paginated object
* @property {Object[]} items - The chunk of items for the current page
* @property {number} page - The current page
* @property {number} maxPage - The maximum page
* @property {number} pageLength - The numer of items per page
* @property {string} pageText - The current page string ("page x of y")
* @see {@link BotUtil.paginate}
*/
paginate(items, page = 1, pageLength = 10) {
return this.constructor.paginate(items, page, pageLength);
}
/**
* Paginate an array of items
* @param {Object[]} items - An array of items to paginate
* @param {number} [page=1] - The page to select
* @param {number} [pageLength=10] - The number of items per page
* @return {Object} The resulting paginated object
* @property {Object[]} items - The chunk of items for the current page
* @property {number} page - The current page
* @property {number} maxPage - The maximum page
* @property {number} pageLength - The numer of items per page
* @property {string} pageText - The current page string ("page x of y")
* @see {@link BotUtil#paginate}
*/
static paginate(items, page = 1, pageLength = 10) {
const maxPage = Math.ceil(items.length / pageLength);
if(page < 1) page = 1;
if(page > maxPage) page = maxPage;
let startIndex = (page - 1) * pageLength;
return {
items: items.length > pageLength ? items.slice(startIndex, startIndex + pageLength) : items,
page: page,
maxPage: maxPage,
pageLength: pageLength,
pageText: `page ${page} of ${maxPage}`
};
}
/**
* Search for matches in a list of items
* @param {Object[]} items - An array of items to search in
* @param {string} searchString - The string to search for
* @param {SearchOptions} options - An options object
* @return {Object[]} The matched items
* @see {@link BotUtil.search}
*/
search(items, searchString, { property = 'name', searchInexact = true, searchExact = true, useStartsWith = false } = {}) {
return this.constructor.search(items, searchString, { property: property, searchInexact: searchInexact, searchExact: searchExact, useStartsWith: useStartsWith });
}
/**
* Search for matches in a list of items
* @param {Object[]} items - An array of items to search in
* @param {string} searchString - The string to search for
* @param {SearchOptions} options - An options object
* @return {Object[]} The matched items
* @see {@link BotUtil#search}
*/
static search(items, searchString, { property = 'name', searchInexact = true, searchExact = true, useStartsWith = false } = {}) {
if(!items || items.length === 0) return [];
if(!searchString) return items;
const lowercaseSearch = searchString.toLowerCase();
let matchedItems;
// Find all items that start with or include the search string
if(searchInexact) {
if(useStartsWith && searchString.length === 1) {
matchedItems = items.filter(element => String(property ? element[property] : element)
.normalize('NFKD')
.toLowerCase()
.startsWith(lowercaseSearch)
);
} else {
matchedItems = items.filter(element => String(property ? element[property] : element)
.normalize('NFKD')
.toLowerCase()
.includes(lowercaseSearch)
);
}
} else {
matchedItems = items;
}
// See if any are an exact match
if(searchExact && matchedItems.length > 1) {
const exactItems = matchedItems.filter(element => String(property ? element[property] : element).normalize('NFKD').toLowerCase() === lowercaseSearch);
if(exactItems.length > 0) return exactItems;
}
return matchedItems;
}
/**
* Splits a string using specified characters into multiple strings of a maximum length
* @param {string} text - The string to split
* @param {number} [maxLength=1925] - The maximum length of each split string
* @param {string} [splitOn=\n] - The characters to split the string with
* @param {string} [prepend] - String to prepend to every split message
* @param {string} [append] - String to append to every split message
* @return {string[]} The split strings
* @see {@link BotUtil.split}
*/
split(text, maxLength = 1925, splitOn = '\n', prepend = '', append = '') {
return this.constructor.split(text, maxLength, splitOn, prepend, append);
}
/**
* Splits a string using specified characters into multiple strings of a maximum length
* @param {string} text - The string to split
* @param {number} [maxLength=1925] - The maximum length of each split string
* @param {string} [splitOn=\n] - The characters to split the string with
* @param {string} [prepend] - String to prepend to every split message
* @param {string} [append] - String to append to every split message
* @return {string[]} The split strings
* @see {@link BotUtil#split}
*/
static split(text, maxLength = 1900, splitOn = '\n', prepend = '', append = '') {
const splitText = text.split(splitOn);
if(splitText.length === 1 && text.length > maxLength) throw new Error('Message exceeds the max length and contains no split characters.');
const messages = [''];
let msg = 0;
for(let i = 0; i < splitText.length; i++) {
if(messages[msg].length + splitText[i].length + 1 > maxLength) {
messages[msg] += append;
messages.push(prepend);
msg++;
}
messages[msg] += (messages[msg].length > 0 && messages[msg] !== prepend ? splitOn : '') + splitText[i];
}
return messages;
}
/**
* Escapes Markdown in the string
* @param {string} text - The text to escape
* @returns {string} The escaped text
* @see {@link BotUtil.escapeMarkdown}
*/
escapeMarkdown(text) {
return this.constructor.escapeMarkdown(text);
}
/**
* Escapes Markdown in the string
* @param {string} text - The text to escape
* @returns {string} The escaped text
* @see {@link BotUtil#escapeMarkdown}
*/
static escapeMarkdown(text) {
return text.replace(/\\(\*|_|`|~|\\)/g, '$1').replace(/(\*|_|`|~|\\)/g, '\\$1');
}
/**
* Convert spaces to non-breaking spaces
* @param {string} text - The text to convert
* @return {string} The converted text
* @see {@link BotUtil.nbsp}
*/
nbsp(text) {
return this.constructor.nbsp(text);
}
/**
* Convert spaces to non-breaking spaces
* @param {string} text - The text to convert
* @return {string} The converted text
* @see {@link BotUtil#nbsp}
*/
static nbsp(text) {
return String(text).replace(spacePattern, nbsp);
}
/**
* @type {PatternConstants}
* @see {@link BotUtil#patterns}
*/
static get patterns() {
return patterns;
}
}
const nbsp = '\xa0';
const spacePattern = / /g;
/**
* @typedef {Object} PatternConstants
* @property {RegExp} userID - A pattern to match a user ID from a raw ID string or mention
* @property {RegExp} roleID - A pattern to match a role ID from a raw ID string or mention
* @property {RegExp} channelID - A pattern to match a channel ID from a raw ID string or mention
* @property {RegExp} allUserMentions - A pattern to to match any mentions that would notify users
*/
const patterns = {
userID: /^(?:<@!?)?([0-9]+)>?$/,
roleID: /^(?:<@&)?([0-9]+)>?$/,
channelID: /^(?:<#)?([0-9]+)>?$/,
anyUserMentions: /@everyone|@here|<@(?:!|&)?[0-9]+>/i
};
/**
* @typedef {Object} SearchOptions
* @property {string} [property=name] - The property on items to search against. If empty, the raw object's toString will be used instead.
* @property {boolean} [searchInexact=true] - Whether or not to search for inexact matches
* @property {boolean} [searchExact=true] - Whether or not to search for exact matches (will narrow down inexact matches if applicable)
* @property {boolean} [useStartsWith=false] - Whether or not to search inexact by checking to see if the item starts with the search string rather than contains,
* if the search string is only one character
*/