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;