buddy

node MVC discord bot
Log | Files | Refs | README

Util.js (19403B)


      1 'use strict';
      2 
      3 const { parse } = require('path');
      4 const fetch = require('node-fetch');
      5 const { Colors, DefaultOptions, Endpoints } = require('./Constants');
      6 const { Error: DiscordError, RangeError, TypeError } = require('../errors');
      7 const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
      8 const isObject = d => typeof d === 'object' && d !== null;
      9 
     10 /**
     11  * Contains various general-purpose utility methods. These functions are also available on the base `Discord` object.
     12  */
     13 class Util {
     14   constructor() {
     15     throw new Error(`The ${this.constructor.name} class may not be instantiated.`);
     16   }
     17 
     18   /**
     19    * Flatten an object. Any properties that are collections will get converted to an array of keys.
     20    * @param {Object} obj The object to flatten.
     21    * @param {...Object<string, boolean|string>} [props] Specific properties to include/exclude.
     22    * @returns {Object}
     23    */
     24   static flatten(obj, ...props) {
     25     if (!isObject(obj)) return obj;
     26 
     27     props = Object.assign(
     28       ...Object.keys(obj)
     29         .filter(k => !k.startsWith('_'))
     30         .map(k => ({ [k]: true })),
     31       ...props,
     32     );
     33 
     34     const out = {};
     35 
     36     for (let [prop, newProp] of Object.entries(props)) {
     37       if (!newProp) continue;
     38       newProp = newProp === true ? prop : newProp;
     39 
     40       const element = obj[prop];
     41       const elemIsObj = isObject(element);
     42       const valueOf = elemIsObj && typeof element.valueOf === 'function' ? element.valueOf() : null;
     43 
     44       // If it's a Collection, make the array of keys
     45       if (element instanceof require('./Collection')) out[newProp] = Array.from(element.keys());
     46       // If the valueOf is a Collection, use its array of keys
     47       else if (valueOf instanceof require('./Collection')) out[newProp] = Array.from(valueOf.keys());
     48       // If it's an array, flatten each element
     49       else if (Array.isArray(element)) out[newProp] = element.map(e => Util.flatten(e));
     50       // If it's an object with a primitive `valueOf`, use that value
     51       else if (typeof valueOf !== 'object') out[newProp] = valueOf;
     52       // If it's a primitive
     53       else if (!elemIsObj) out[newProp] = element;
     54     }
     55 
     56     return out;
     57   }
     58 
     59   /**
     60    * Splits a string into multiple chunks at a designated character that do not exceed a specific length.
     61    * @param {StringResolvable} text Content to split
     62    * @param {SplitOptions} [options] Options controlling the behavior of the split
     63    * @returns {string[]}
     64    */
     65   static splitMessage(text, { maxLength = 2000, char = '\n', prepend = '', append = '' } = {}) {
     66     text = Util.resolveString(text);
     67     if (text.length <= maxLength) return [text];
     68     const splitText = text.split(char);
     69     if (splitText.some(chunk => chunk.length > maxLength)) throw new RangeError('SPLIT_MAX_LEN');
     70     const messages = [];
     71     let msg = '';
     72     for (const chunk of splitText) {
     73       if (msg && (msg + char + chunk + append).length > maxLength) {
     74         messages.push(msg + append);
     75         msg = prepend;
     76       }
     77       msg += (msg && msg !== prepend ? char : '') + chunk;
     78     }
     79     return messages.concat(msg).filter(m => m);
     80   }
     81 
     82   /**
     83    * Escapes any Discord-flavour markdown in a string.
     84    * @param {string} text Content to escape
     85    * @param {Object} [options={}] What types of markdown to escape
     86    * @param {boolean} [options.codeBlock=true] Whether to escape code blocks or not
     87    * @param {boolean} [options.inlineCode=true] Whether to escape inline code or not
     88    * @param {boolean} [options.bold=true] Whether to escape bolds or not
     89    * @param {boolean} [options.italic=true] Whether to escape italics or not
     90    * @param {boolean} [options.underline=true] Whether to escape underlines or not
     91    * @param {boolean} [options.strikethrough=true] Whether to escape strikethroughs or not
     92    * @param {boolean} [options.spoiler=true] Whether to escape spoilers or not
     93    * @param {boolean} [options.codeBlockContent=true] Whether to escape text inside code blocks or not
     94    * @param {boolean} [options.inlineCodeContent=true] Whether to escape text inside inline code or not
     95    * @returns {string}
     96    */
     97   static escapeMarkdown(
     98     text,
     99     {
    100       codeBlock = true,
    101       inlineCode = true,
    102       bold = true,
    103       italic = true,
    104       underline = true,
    105       strikethrough = true,
    106       spoiler = true,
    107       codeBlockContent = true,
    108       inlineCodeContent = true,
    109     } = {},
    110   ) {
    111     if (!codeBlockContent) {
    112       return text
    113         .split('```')
    114         .map((subString, index, array) => {
    115           if (index % 2 && index !== array.length - 1) return subString;
    116           return Util.escapeMarkdown(subString, {
    117             inlineCode,
    118             bold,
    119             italic,
    120             underline,
    121             strikethrough,
    122             spoiler,
    123             inlineCodeContent,
    124           });
    125         })
    126         .join(codeBlock ? '\\`\\`\\`' : '```');
    127     }
    128     if (!inlineCodeContent) {
    129       return text
    130         .split(/(?<=^|[^`])`(?=[^`]|$)/g)
    131         .map((subString, index, array) => {
    132           if (index % 2 && index !== array.length - 1) return subString;
    133           return Util.escapeMarkdown(subString, {
    134             codeBlock,
    135             bold,
    136             italic,
    137             underline,
    138             strikethrough,
    139             spoiler,
    140           });
    141         })
    142         .join(inlineCode ? '\\`' : '`');
    143     }
    144     if (inlineCode) text = Util.escapeInlineCode(text);
    145     if (codeBlock) text = Util.escapeCodeBlock(text);
    146     if (italic) text = Util.escapeItalic(text);
    147     if (bold) text = Util.escapeBold(text);
    148     if (underline) text = Util.escapeUnderline(text);
    149     if (strikethrough) text = Util.escapeStrikethrough(text);
    150     if (spoiler) text = Util.escapeSpoiler(text);
    151     return text;
    152   }
    153 
    154   /**
    155    * Escapes code block markdown in a string.
    156    * @param {string} text Content to escape
    157    * @returns {string}
    158    */
    159   static escapeCodeBlock(text) {
    160     return text.replace(/```/g, '\\`\\`\\`');
    161   }
    162 
    163   /**
    164    * Escapes inline code markdown in a string.
    165    * @param {string} text Content to escape
    166    * @returns {string}
    167    */
    168   static escapeInlineCode(text) {
    169     return text.replace(/(?<=^|[^`])`(?=[^`]|$)/g, '\\`');
    170   }
    171 
    172   /**
    173    * Escapes italic markdown in a string.
    174    * @param {string} text Content to escape
    175    * @returns {string}
    176    */
    177   static escapeItalic(text) {
    178     let i = 0;
    179     text = text.replace(/(?<=^|[^*])\*([^*]|\*\*|$)/g, (_, match) => {
    180       if (match === '**') return ++i % 2 ? `\\*${match}` : `${match}\\*`;
    181       return `\\*${match}`;
    182     });
    183     i = 0;
    184     return text.replace(/(?<=^|[^_])_([^_]|__|$)/g, (_, match) => {
    185       if (match === '__') return ++i % 2 ? `\\_${match}` : `${match}\\_`;
    186       return `\\_${match}`;
    187     });
    188   }
    189 
    190   /**
    191    * Escapes bold markdown in a string.
    192    * @param {string} text Content to escape
    193    * @returns {string}
    194    */
    195   static escapeBold(text) {
    196     let i = 0;
    197     return text.replace(/\*\*(\*)?/g, (_, match) => {
    198       if (match) return ++i % 2 ? `${match}\\*\\*` : `\\*\\*${match}`;
    199       return '\\*\\*';
    200     });
    201   }
    202 
    203   /**
    204    * Escapes underline markdown in a string.
    205    * @param {string} text Content to escape
    206    * @returns {string}
    207    */
    208   static escapeUnderline(text) {
    209     let i = 0;
    210     return text.replace(/__(_)?/g, (_, match) => {
    211       if (match) return ++i % 2 ? `${match}\\_\\_` : `\\_\\_${match}`;
    212       return '\\_\\_';
    213     });
    214   }
    215 
    216   /**
    217    * Escapes strikethrough markdown in a string.
    218    * @param {string} text Content to escape
    219    * @returns {string}
    220    */
    221   static escapeStrikethrough(text) {
    222     return text.replace(/~~/g, '\\~\\~');
    223   }
    224 
    225   /**
    226    * Escapes spoiler markdown in a string.
    227    * @param {string} text Content to escape
    228    * @returns {string}
    229    */
    230   static escapeSpoiler(text) {
    231     return text.replace(/\|\|/g, '\\|\\|');
    232   }
    233 
    234   /**
    235    * Gets the recommended shard count from Discord.
    236    * @param {string} token Discord auth token
    237    * @param {number} [guildsPerShard=1000] Number of guilds per shard
    238    * @returns {Promise<number>} The recommended number of shards
    239    */
    240   static fetchRecommendedShards(token, guildsPerShard = 1000) {
    241     if (!token) throw new DiscordError('TOKEN_MISSING');
    242     return fetch(`${DefaultOptions.http.api}/v${DefaultOptions.http.version}${Endpoints.botGateway}`, {
    243       method: 'GET',
    244       headers: { Authorization: `Bot ${token.replace(/^Bot\s*/i, '')}` },
    245     })
    246       .then(res => {
    247         if (res.ok) return res.json();
    248         throw res;
    249       })
    250       .then(data => data.shards * (1000 / guildsPerShard));
    251   }
    252 
    253   /**
    254    * Parses emoji info out of a string. The string must be one of:
    255    * * A UTF-8 emoji (no ID)
    256    * * A URL-encoded UTF-8 emoji (no ID)
    257    * * A Discord custom emoji (`<:name:id>` or `<a:name:id>`)
    258    * @param {string} text Emoji string to parse
    259    * @returns {Object} Object with `animated`, `name`, and `id` properties
    260    * @private
    261    */
    262   static parseEmoji(text) {
    263     if (text.includes('%')) text = decodeURIComponent(text);
    264     if (!text.includes(':')) return { animated: false, name: text, id: null };
    265     const m = text.match(/<?(?:(a):)?(\w{2,32}):(\d{17,19})?>?/);
    266     if (!m) return null;
    267     return { animated: Boolean(m[1]), name: m[2], id: m[3] || null };
    268   }
    269 
    270   /**
    271    * Shallow-copies an object with its class/prototype intact.
    272    * @param {Object} obj Object to clone
    273    * @returns {Object}
    274    * @private
    275    */
    276   static cloneObject(obj) {
    277     return Object.assign(Object.create(obj), obj);
    278   }
    279 
    280   /**
    281    * Sets default properties on an object that aren't already specified.
    282    * @param {Object} def Default properties
    283    * @param {Object} given Object to assign defaults to
    284    * @returns {Object}
    285    * @private
    286    */
    287   static mergeDefault(def, given) {
    288     if (!given) return def;
    289     for (const key in def) {
    290       if (!has(given, key) || given[key] === undefined) {
    291         given[key] = def[key];
    292       } else if (given[key] === Object(given[key])) {
    293         given[key] = Util.mergeDefault(def[key], given[key]);
    294       }
    295     }
    296 
    297     return given;
    298   }
    299 
    300   /**
    301    * Converts an ArrayBuffer or string to a Buffer.
    302    * @param {ArrayBuffer|string} ab ArrayBuffer to convert
    303    * @returns {Buffer}
    304    * @private
    305    */
    306   static convertToBuffer(ab) {
    307     if (typeof ab === 'string') ab = Util.str2ab(ab);
    308     return Buffer.from(ab);
    309   }
    310 
    311   /**
    312    * Converts a string to an ArrayBuffer.
    313    * @param {string} str String to convert
    314    * @returns {ArrayBuffer}
    315    * @private
    316    */
    317   static str2ab(str) {
    318     const buffer = new ArrayBuffer(str.length * 2);
    319     const view = new Uint16Array(buffer);
    320     for (var i = 0, strLen = str.length; i < strLen; i++) view[i] = str.charCodeAt(i);
    321     return buffer;
    322   }
    323 
    324   /**
    325    * Makes an Error from a plain info object.
    326    * @param {Object} obj Error info
    327    * @param {string} obj.name Error type
    328    * @param {string} obj.message Message for the error
    329    * @param {string} obj.stack Stack for the error
    330    * @returns {Error}
    331    * @private
    332    */
    333   static makeError(obj) {
    334     const err = new Error(obj.message);
    335     err.name = obj.name;
    336     err.stack = obj.stack;
    337     return err;
    338   }
    339 
    340   /**
    341    * Makes a plain error info object from an Error.
    342    * @param {Error} err Error to get info from
    343    * @returns {Object}
    344    * @private
    345    */
    346   static makePlainError(err) {
    347     return {
    348       name: err.name,
    349       message: err.message,
    350       stack: err.stack,
    351     };
    352   }
    353 
    354   /**
    355    * Moves an element in an array *in place*.
    356    * @param {Array<*>} array Array to modify
    357    * @param {*} element Element to move
    358    * @param {number} newIndex Index or offset to move the element to
    359    * @param {boolean} [offset=false] Move the element by an offset amount rather than to a set index
    360    * @returns {number}
    361    * @private
    362    */
    363   static moveElementInArray(array, element, newIndex, offset = false) {
    364     const index = array.indexOf(element);
    365     newIndex = (offset ? index : 0) + newIndex;
    366     if (newIndex > -1 && newIndex < array.length) {
    367       const removedElement = array.splice(index, 1)[0];
    368       array.splice(newIndex, 0, removedElement);
    369     }
    370     return array.indexOf(element);
    371   }
    372 
    373   /**
    374    * Data that can be resolved to give a string. This can be:
    375    * * A string
    376    * * An array (joined with a new line delimiter to give a string)
    377    * * Any value
    378    * @typedef {string|Array|*} StringResolvable
    379    */
    380 
    381   /**
    382    * Resolves a StringResolvable to a string.
    383    * @param {StringResolvable} data The string resolvable to resolve
    384    * @returns {string}
    385    */
    386   static resolveString(data) {
    387     if (typeof data === 'string') return data;
    388     if (Array.isArray(data)) return data.join('\n');
    389     return String(data);
    390   }
    391 
    392   /**
    393    * Can be a number, hex string, an RGB array like:
    394    * ```js
    395    * [255, 0, 255] // purple
    396    * ```
    397    * or one of the following strings:
    398    * - `DEFAULT`
    399    * - `WHITE`
    400    * - `AQUA`
    401    * - `GREEN`
    402    * - `BLUE`
    403    * - `YELLOW`
    404    * - `PURPLE`
    405    * - `LUMINOUS_VIVID_PINK`
    406    * - `GOLD`
    407    * - `ORANGE`
    408    * - `RED`
    409    * - `GREY`
    410    * - `DARKER_GREY`
    411    * - `NAVY`
    412    * - `DARK_AQUA`
    413    * - `DARK_GREEN`
    414    * - `DARK_BLUE`
    415    * - `DARK_PURPLE`
    416    * - `DARK_VIVID_PINK`
    417    * - `DARK_GOLD`
    418    * - `DARK_ORANGE`
    419    * - `DARK_RED`
    420    * - `DARK_GREY`
    421    * - `LIGHT_GREY`
    422    * - `DARK_NAVY`
    423    * - `RANDOM`
    424    * @typedef {string|number|number[]} ColorResolvable
    425    */
    426 
    427   /**
    428    * Resolves a ColorResolvable into a color number.
    429    * @param {ColorResolvable} color Color to resolve
    430    * @returns {number} A color
    431    */
    432   static resolveColor(color) {
    433     if (typeof color === 'string') {
    434       if (color === 'RANDOM') return Math.floor(Math.random() * (0xffffff + 1));
    435       if (color === 'DEFAULT') return 0;
    436       color = Colors[color] || parseInt(color.replace('#', ''), 16);
    437     } else if (Array.isArray(color)) {
    438       color = (color[0] << 16) + (color[1] << 8) + color[2];
    439     }
    440 
    441     if (color < 0 || color > 0xffffff) throw new RangeError('COLOR_RANGE');
    442     else if (color && isNaN(color)) throw new TypeError('COLOR_CONVERT');
    443 
    444     return color;
    445   }
    446 
    447   /**
    448    * Sorts by Discord's position and ID.
    449    * @param  {Collection} collection Collection of objects to sort
    450    * @returns {Collection}
    451    */
    452   static discordSort(collection) {
    453     return collection.sorted(
    454       (a, b) =>
    455         a.rawPosition - b.rawPosition ||
    456         parseInt(b.id.slice(0, -10)) - parseInt(a.id.slice(0, -10)) ||
    457         parseInt(b.id.slice(10)) - parseInt(a.id.slice(10)),
    458     );
    459   }
    460 
    461   /**
    462    * Sets the position of a Channel or Role.
    463    * @param {Channel|Role} item Object to set the position of
    464    * @param {number} position New position for the object
    465    * @param {boolean} relative Whether `position` is relative to its current position
    466    * @param {Collection<string, Channel|Role>} sorted A collection of the objects sorted properly
    467    * @param {APIRouter} route Route to call PATCH on
    468    * @param {string} [reason] Reason for the change
    469    * @returns {Promise<Object[]>} Updated item list, with `id` and `position` properties
    470    * @private
    471    */
    472   static setPosition(item, position, relative, sorted, route, reason) {
    473     let updatedItems = sorted.array();
    474     Util.moveElementInArray(updatedItems, item, position, relative);
    475     updatedItems = updatedItems.map((r, i) => ({ id: r.id, position: i }));
    476     return route.patch({ data: updatedItems, reason }).then(() => updatedItems);
    477   }
    478 
    479   /**
    480    * Alternative to Node's `path.basename`, removing query string after the extension if it exists.
    481    * @param {string} path Path to get the basename of
    482    * @param {string} [ext] File extension to remove
    483    * @returns {string} Basename of the path
    484    * @private
    485    */
    486   static basename(path, ext) {
    487     let res = parse(path);
    488     return ext && res.ext.startsWith(ext) ? res.name : res.base.split('?')[0];
    489   }
    490 
    491   /**
    492    * Transforms a snowflake from a decimal string to a bit string.
    493    * @param  {Snowflake} num Snowflake to be transformed
    494    * @returns {string}
    495    * @private
    496    */
    497   static idToBinary(num) {
    498     let bin = '';
    499     let high = parseInt(num.slice(0, -10)) || 0;
    500     let low = parseInt(num.slice(-10));
    501     while (low > 0 || high > 0) {
    502       bin = String(low & 1) + bin;
    503       low = Math.floor(low / 2);
    504       if (high > 0) {
    505         low += 5000000000 * (high % 2);
    506         high = Math.floor(high / 2);
    507       }
    508     }
    509     return bin;
    510   }
    511 
    512   /**
    513    * Transforms a snowflake from a bit string to a decimal string.
    514    * @param  {string} num Bit string to be transformed
    515    * @returns {Snowflake}
    516    * @private
    517    */
    518   static binaryToID(num) {
    519     let dec = '';
    520 
    521     while (num.length > 50) {
    522       const high = parseInt(num.slice(0, -32), 2);
    523       const low = parseInt((high % 10).toString(2) + num.slice(-32), 2);
    524 
    525       dec = (low % 10).toString() + dec;
    526       num =
    527         Math.floor(high / 10).toString(2) +
    528         Math.floor(low / 10)
    529           .toString(2)
    530           .padStart(32, '0');
    531     }
    532 
    533     num = parseInt(num, 2);
    534     while (num > 0) {
    535       dec = (num % 10).toString() + dec;
    536       num = Math.floor(num / 10);
    537     }
    538 
    539     return dec;
    540   }
    541 
    542   /**
    543    * Breaks user, role and everyone/here mentions by adding a zero width space after every @ character
    544    * @param {string} str The string to sanitize
    545    * @returns {string}
    546    */
    547   static removeMentions(str) {
    548     return str.replace(/@/g, '@\u200b');
    549   }
    550 
    551   /**
    552    * The content to have all mentions replaced by the equivalent text.
    553    * @param {string} str The string to be converted
    554    * @param {Message} message The message object to reference
    555    * @returns {string}
    556    */
    557   static cleanContent(str, message) {
    558     str = str
    559       .replace(/<@!?[0-9]+>/g, input => {
    560         const id = input.replace(/<|!|>|@/g, '');
    561         if (message.channel.type === 'dm') {
    562           const user = message.client.users.cache.get(id);
    563           return user ? `@${user.username}` : input;
    564         }
    565 
    566         const member = message.channel.guild.members.cache.get(id);
    567         if (member) {
    568           return `@${member.displayName}`;
    569         } else {
    570           const user = message.client.users.cache.get(id);
    571           return user ? `@${user.username}` : input;
    572         }
    573       })
    574       .replace(/<#[0-9]+>/g, input => {
    575         const channel = message.client.channels.cache.get(input.replace(/<|#|>/g, ''));
    576         return channel ? `#${channel.name}` : input;
    577       })
    578       .replace(/<@&[0-9]+>/g, input => {
    579         if (message.channel.type === 'dm') return input;
    580         const role = message.guild.roles.cache.get(input.replace(/<|@|>|&/g, ''));
    581         return role ? `@${role.name}` : input;
    582       });
    583     if (message.client.options.disableMentions === 'everyone') {
    584       str = str.replace(/@([^<>@ ]*)/gmsu, (match, target) => {
    585         if (target.match(/^[&!]?\d+$/)) {
    586           return `@${target}`;
    587         } else {
    588           return `@\u200b${target}`;
    589         }
    590       });
    591     }
    592     if (message.client.options.disableMentions === 'all') {
    593       return Util.removeMentions(str);
    594     } else {
    595       return str;
    596     }
    597   }
    598 
    599   /**
    600    * The content to put in a codeblock with all codeblock fences replaced by the equivalent backticks.
    601    * @param {string} text The string to be converted
    602    * @returns {string}
    603    */
    604   static cleanCodeBlockContent(text) {
    605     return text.replace(/```/g, '`\u200b``');
    606   }
    607 
    608   /**
    609    * Creates a Promise that resolves after a specified duration.
    610    * @param {number} ms How long to wait before resolving (in milliseconds)
    611    * @returns {Promise<void>}
    612    * @private
    613    */
    614   static delayFor(ms) {
    615     return new Promise(resolve => {
    616       setTimeout(resolve, ms);
    617     });
    618   }
    619 }
    620 
    621 module.exports = Util;