diff --git a/.eslintrc.json b/.eslintrc.json index 8b21a1523..979a9acc1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -127,7 +127,11 @@ "semi-spacing": "error", "semi": "error", "space-before-blocks": "error", - "space-before-function-paren": ["error", "never"], + "space-before-function-paren": ["error", { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], "space-in-parens": "error", "space-infix-ops": "error", "space-unary-ops": "error", diff --git a/.travis.yml b/.travis.yml index fb2f6214c..3b89d2afa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,19 +1,19 @@ language: node_js node_js: - - "8" + - 8 + - 9 +install: npm install +script: bash ./travis/test.sh +jobs: + include: + - stage: deploy + node_js: 9 + script: bash ./travis/deploy.sh + env: + - ENCRYPTION_LABEL="af862fa96d3e" + - COMMIT_AUTHOR_EMAIL="amishshah.2k@gmail.com" cache: directories: - node_modules -install: npm install -jobs: - include: - - stage: test - script: bash ./travis/test.sh - - stage: deploy - script: bash ./travis/deploy.sh -env: - global: - - ENCRYPTION_LABEL: "af862fa96d3e" - - COMMIT_AUTHOR_EMAIL: "amishshah.2k@gmail.com" dist: trusty sudo: false diff --git a/docs/general/welcome.md b/docs/general/welcome.md index 11815ee13..84b06ed33 100644 --- a/docs/general/welcome.md +++ b/docs/general/welcome.md @@ -1,7 +1,7 @@

- discord.js +


diff --git a/package.json b/package.json index 4e2400baa..f99a4ebdd 100644 --- a/package.json +++ b/package.json @@ -32,12 +32,11 @@ "homepage": "https://github.com/hydrabolt/discord.js#readme", "runkitExampleFilename": "./docs/examples/ping.js", "dependencies": { - "long": "^3.0.0", "pako": "^1.0.0", "prism-media": "github:hydrabolt/prism-media#indev", - "snekfetch": "^3.0.0", + "snekfetch": "^3.5.0", "tweetnacl": "^1.0.0", - "ws": "^3.0.0" + "ws": "^3.3.1" }, "peerDependencies": { "bufferutil": "^3.0.0", @@ -50,12 +49,12 @@ "devDependencies": { "@types/node": "^8.0.0", "discord.js-docgen": "hydrabolt/discord.js-docgen", - "eslint": "^4.0.0", + "eslint": "^4.11.0", "jsdoc-strip-async-await": "^0.1.0", "json-filter-loader": "^1.0.0", - "parallel-webpack": "^2.0.0", + "parallel-webpack": "^2.2.0", "uglifyjs-webpack-plugin": "^1.0.0-beta.2", - "webpack": "^3.0.0" + "webpack": "^3.8.0" }, "engines": { "node": ">=8.0.0" diff --git a/src/client/BaseClient.js b/src/client/BaseClient.js index 365e9dcb7..f2c91ccdf 100644 --- a/src/client/BaseClient.js +++ b/src/client/BaseClient.js @@ -42,6 +42,7 @@ class BaseClient extends EventEmitter { /** * API shortcut * @type {Object} + * @readonly * @private */ get api() { diff --git a/src/client/Client.js b/src/client/Client.js index b26b73e2e..4b335e88a 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -163,6 +163,7 @@ class Client extends BaseClient { /** * Timestamp of the latest ping's start time * @type {number} + * @readonly * @private */ get _pingTimestamp() { diff --git a/src/client/WebhookClient.js b/src/client/WebhookClient.js index bc413cef6..c4c297879 100644 --- a/src/client/WebhookClient.js +++ b/src/client/WebhookClient.js @@ -3,7 +3,7 @@ const BaseClient = require('./BaseClient'); /** * The webhook client. - * @extends {Webhook} + * @implements {Webhook} * @extends {BaseClient} */ class WebhookClient extends BaseClient { diff --git a/src/client/voice/util/Secretbox.js b/src/client/voice/util/Secretbox.js index 31f5b8d00..b21fb8f9d 100644 --- a/src/client/voice/util/Secretbox.js +++ b/src/client/voice/util/Secretbox.js @@ -15,7 +15,7 @@ const libs = { exports.methods = {}; -(async() => { +(async () => { for (const libName of Object.keys(libs)) { try { const lib = require(libName); diff --git a/src/client/websocket/packets/handlers/Ready.js b/src/client/websocket/packets/handlers/Ready.js index 4fc5363cf..b1a833d5f 100644 --- a/src/client/websocket/packets/handlers/Ready.js +++ b/src/client/websocket/packets/handlers/Ready.js @@ -1,6 +1,6 @@ const AbstractHandler = require('./AbstractHandler'); const { Events } = require('../../../../util/Constants'); -const ClientUser = require('../../../../structures/ClientUser'); +let ClientUser; class ReadyHandler extends AbstractHandler { handle(packet) { @@ -12,6 +12,7 @@ class ReadyHandler extends AbstractHandler { data.user.user_settings = data.user_settings; data.user.user_guild_settings = data.user_guild_settings; + if (!ClientUser) ClientUser = require('../../../../structures/ClientUser'); const clientUser = new ClientUser(client, data.user); client.user = clientUser; client.readyAt = new Date(); diff --git a/src/errors/Messages.js b/src/errors/Messages.js index c3116a203..545452703 100644 --- a/src/errors/Messages.js +++ b/src/errors/Messages.js @@ -20,9 +20,13 @@ const Messages = { SHARDING_REQUIRED: 'This session would have handled too many guilds - Sharding is required.', SHARDING_CHILD_CONNECTION: 'Failed to send message to shard\'s process.', SHARDING_PARENT_CONNECTION: 'Failed to send message to master process.', - SHARDING_NO_SHARDS: 'No shards have been spawned', - SHARDING_IN_PROCESS: 'Shards are still being spawned', - SHARDING_ALREADY_SPAWNED: count => `Already spawned ${count} shards`, + SHARDING_NO_SHARDS: 'No shards have been spawned.', + SHARDING_IN_PROCESS: 'Shards are still being spawned.', + SHARDING_ALREADY_SPAWNED: count => `Already spawned ${count} shards.`, + SHARDING_PROCESS_EXISTS: id => `Shard ${id} already has an active process.`, + SHARDING_READY_TIMEOUT: id => `Shard ${id}'s Client took too long to become ready.`, + SHARDING_READY_DISCONNECTED: id => `Shard ${id}'s Client disconnected before becoming ready.`, + SHARDING_READY_DIED: id => `Shard ${id}'s process exited before its Client became ready.`, COLOR_RANGE: 'Color must be within the range 0 - 16777215 (0xFFFFFF).', COLOR_CONVERT: 'Unable to convert color to a number.', diff --git a/src/index.js b/src/index.js index 7bea36f1b..42de34564 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ module.exports = { Permissions: require('./util/Permissions'), Snowflake: require('./util/Snowflake'), SnowflakeUtil: require('./util/Snowflake'), + Structures: require('./util/Structures'), Util: Util, util: Util, version: require('../package.json').version, @@ -29,14 +30,18 @@ module.exports = { GuildChannelStore: require('./stores/GuildChannelStore'), GuildMemberStore: require('./stores/GuildMemberStore'), GuildStore: require('./stores/GuildStore'), + ReactionUserStore: require('./stores/ReactionUserStore'), MessageStore: require('./stores/MessageStore'), PresenceStore: require('./stores/PresenceStore'), RoleStore: require('./stores/RoleStore'), UserStore: require('./stores/UserStore'), // Shortcuts to Util methods + discordSort: Util.discordSort, escapeMarkdown: Util.escapeMarkdown, fetchRecommendedShards: Util.fetchRecommendedShards, + resolveColor: Util.resolveColor, + resolveString: Util.resolveString, splitMessage: Util.splitMessage, // Structures @@ -45,7 +50,10 @@ module.exports = { CategoryChannel: require('./structures/CategoryChannel'), Channel: require('./structures/Channel'), ClientApplication: require('./structures/ClientApplication'), - ClientUser: require('./structures/ClientUser'), + get ClientUser() { + // This is a getter so that it properly extends any custom User class + return require('./structures/ClientUser'); + }, ClientUserChannelOverride: require('./structures/ClientUserChannelOverride'), ClientUserGuildSettings: require('./structures/ClientUserGuildSettings'), ClientUserSettings: require('./structures/ClientUserSettings'), diff --git a/src/rest/handlers/RequestHandler.js b/src/rest/handlers/RequestHandler.js index 64e9ea72b..c4226a45c 100644 --- a/src/rest/handlers/RequestHandler.js +++ b/src/rest/handlers/RequestHandler.js @@ -39,12 +39,13 @@ class RequestHandler { /** * Emitted when the client hits a rate limit while making a request * @event Client#rateLimit - * @prop {number} timeout Timeout in ms - * @prop {number} limit Number of requests that can be made to this endpoint - * @prop {number} timeDifference Delta-T in ms between your system and Discord servers - * @prop {string} method HTTP method used for request that triggered this event - * @prop {string} path Path used for request that triggered this event - * @prop {string} route Route used for request that triggered this event + * @param {Object} rateLimitInfo Object containing the rate limit info + * @param {number} rateLimitInfo.timeout Timeout in ms + * @param {number} rateLimitInfo.limit Number of requests that can be made to this endpoint + * @param {number} rateLimitInfo.timeDifference Delta-T in ms between your system and Discord servers + * @param {string} rateLimitInfo.method HTTP method used for request that triggered this event + * @param {string} rateLimitInfo.path Path used for request that triggered this event + * @param {string} rateLimitInfo.route Route used for request that triggered this event */ this.client.emit(RATE_LIMIT, { timeout, diff --git a/src/sharding/Shard.js b/src/sharding/Shard.js index e67bece78..0e44f91a2 100644 --- a/src/sharding/Shard.js +++ b/src/sharding/Shard.js @@ -1,18 +1,24 @@ const childProcess = require('child_process'); +const EventEmitter = require('events'); const path = require('path'); const Util = require('../util/Util'); const { Error } = require('../errors'); /** - * Represents a Shard spawned by the ShardingManager. + * A self-contained shard created by the {@link ShardingManager}. Each one has a {@link ChildProcess} that contains + * an instance of the bot and its {@link Client}. When its child process exits for any reason, the shard will spawn a + * new one to replace it as necessary. + * @extends EventEmitter */ -class Shard { +class Shard extends EventEmitter { /** - * @param {ShardingManager} manager The sharding manager - * @param {number} id The ID of this shard - * @param {Array} [args=[]] Command line arguments to pass to the script + * @param {ShardingManager} manager Manager that is spawning this shard + * @param {number} id ID of this shard + * @param {string[]} [args=[]] Command line arguments to pass to the script */ constructor(manager, id, args = []) { + super(); + /** * Manager that created the shard * @type {ShardingManager} @@ -26,7 +32,13 @@ class Shard { this.id = id; /** - * The environment variables for the shard + * Arguments for the shard's process + * @type {string[]} + */ + this.args = args; + + /** + * Environment variables for the shard's process * @type {Object} */ this.env = Object.assign({}, process.env, { @@ -36,19 +48,81 @@ class Shard { }); /** - * Process of the shard - * @type {ChildProcess} + * Whether the shard's {@link Client} is ready + * @type {boolean} */ - this.process = childProcess.fork(path.resolve(this.manager.file), args, { - env: this.env, - }); - this.process.on('message', this._handleMessage.bind(this)); - this.process.once('exit', () => { - if (this.manager.respawn) this.manager.createShard(this.id); - }); + this.ready = false; + /** + * Process of the shard + * @type {?ChildProcess} + */ + this.process = null; + + /** + * Ongoing promises for calls to {@link Shard#eval}, mapped by the `script` they were called with + * @type {Map} + * @private + */ this._evals = new Map(); + + /** + * Ongoing promises for calls to {@link Shard#fetchClientValue}, mapped by the `prop` they were called with + * @type {Map} + * @private + */ this._fetches = new Map(); + + /** + * Listener function for the {@link ChildProcess}' `exit` event + * @type {Function} + * @private + */ + this._exitListener = this._handleExit.bind(this, undefined); + } + + /** + * Forks a child process for the shard. + * You should not need to call this manually. + * @param {boolean} [waitForReady=true] Whether to wait until the {@link Client} has become ready before resolving + * @returns {Promise} + */ + async spawn(waitForReady = true) { + if (this.process) throw new Error('SHARDING_PROCESS_EXISTS', this.id); + + this.process = childProcess.fork(path.resolve(this.manager.file), this.args, { env: this.env }) + .on('message', this._handleMessage.bind(this)) + .on('exit', this._exitListener); + + /** + * Emitted upon the creation of the shard's child process. + * @event Shard#spawn + * @param {ChildProcess} process Child process that was created + */ + this.emit('spawn', this.process); + + if (!waitForReady) return this.process; + await new Promise((resolve, reject) => { + this.once('ready', resolve); + this.once('disconnect', () => reject(new Error('SHARDING_READY_DISCONNECTED', this.id))); + this.once('death', () => reject(new Error('SHARDING_READY_DIED', this.id))); + setTimeout(() => reject(new Error('SHARDING_READY_TIMEOUT', this.id)), 30000); + }); + return this.process; + } + + /** + * Kills and restarts the shard's process. + * @param {number} [delay=500] How long to wait between killing the process and restarting it (in milliseconds) + * @param {boolean} [waitForReady=true] Whether to wait the {@link Client} has become ready before resolving + * @returns {Promise} + */ + async respawn(delay = 500, waitForReady = true) { + this.process.removeListener('exit', this._exitListener); + this.process.kill(); + this._handleExit(false); + if (delay > 0) await Util.delayFor(delay); + return this.spawn(waitForReady); } /** @@ -100,7 +174,7 @@ class Shard { } /** - * Evaluates a script on the shard, in the context of the client. + * Evaluates a script on the shard, in the context of the {@link Client}. * @param {string} script JavaScript to run on the shard * @returns {Promise<*>} Result of the script execution */ @@ -134,6 +208,39 @@ class Shard { */ _handleMessage(message) { if (message) { + // Shard is ready + if (message._ready) { + this.ready = true; + /** + * Emitted upon the shard's {@link Client#ready} event. + * @event Shard#ready + */ + this.emit('ready'); + return; + } + + // Shard has disconnected + if (message._disconnect) { + this.ready = false; + /** + * Emitted upon the shard's {@link Client#disconnect} event. + * @event Shard#disconnect + */ + this.emit('disconnect'); + return; + } + + // Shard is attempting to reconnect + if (message._reconnecting) { + this.ready = false; + /** + * Emitted upon the shard's {@link Client#reconnecting} event. + * @event Shard#reconnecting + */ + this.emit('reconnecting'); + return; + } + // Shard is requesting a property fetch if (message._sFetchProp) { this.manager.fetchClientValues(message._sFetchProp).then( @@ -151,15 +258,44 @@ class Shard { ); return; } + + // Shard is requesting a respawn of all shards + if (message._sRespawnAll) { + const { shardDelay, respawnDelay, waitForReady } = message._sRespawnAll; + this.manager.respawnAll(shardDelay, respawnDelay, waitForReady).catch(() => { + // Do nothing + }); + return; + } } /** - * Emitted upon recieving a message from a shard. - * @event ShardingManager#message - * @param {Shard} shard Shard that sent the message + * Emitted upon recieving a message from the child process. + * @event Shard#message * @param {*} message Message that was received */ - this.manager.emit('message', this, message); + this.emit('message', message); + } + + /** + * Handles the shard's process exiting. + * @param {boolean} [respawn=this.manager.respawn] Whether to spawn the shard again + * @private + */ + _handleExit(respawn = this.manager.respawn) { + /** + * Emitted upon the shard's child process exiting. + * @event Shard#death + * @param {ChildProcess} process Child process that exited + */ + this.emit('death', this.process); + + this.ready = false; + this.process = null; + this._evals.clear(); + this._fetches.clear(); + + if (respawn) this.spawn().catch(err => this.emit('error', err)); } } diff --git a/src/sharding/ShardClientUtil.js b/src/sharding/ShardClientUtil.js index aceb85f2d..b0e9d57ed 100644 --- a/src/sharding/ShardClientUtil.js +++ b/src/sharding/ShardClientUtil.js @@ -3,15 +3,19 @@ const { Events } = require('../util/Constants'); const { Error } = require('../errors'); /** - * Helper class for sharded clients spawned as a child process, such as from a ShardingManager. + * Helper class for sharded clients spawned as a child process, such as from a {@link ShardingManager}. + * Utilises IPC to send and receive data to/from the master process and other shards. */ class ShardClientUtil { /** - * @param {Client} client The client of the current shard + * @param {Client} client Client of the current shard */ constructor(client) { this.client = client; process.on('message', this._handleMessage.bind(this)); + client.on('ready', () => { process.send({ _ready: true }); }); + client.on('disconnect', () => { process.send({ _disconnect: true }); }); + client.on('reconnecting', () => { process.send({ _reconnecting: true }); }); } /** @@ -49,13 +53,14 @@ class ShardClientUtil { /** * Fetches a client property value of each shard. * @param {string} prop Name of the client property to get, using periods for nesting - * @returns {Promise} + * @returns {Promise>} * @example * client.shard.fetchClientValues('guilds.size') * .then(results => { * console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`); * }) * .catch(console.error); + * @see {@link ShardingManager#fetchClientValues} */ fetchClientValues(prop) { return new Promise((resolve, reject) => { @@ -74,9 +79,10 @@ class ShardClientUtil { } /** - * Evaluates a script on all shards, in the context of the Clients. + * Evaluates a script on all shards, in the context of the {@link Clients}. * @param {string} script JavaScript to run on each shard - * @returns {Promise} Results of the script execution + * @returns {Promise>} Results of the script execution + * @see {@link ShardingManager#broadcastEval} */ broadcastEval(script) { return new Promise((resolve, reject) => { @@ -94,6 +100,19 @@ class ShardClientUtil { }); } + /** + * Requests a respawn of all shards. + * @param {number} [shardDelay=5000] How long to wait between shards (in milliseconds) + * @param {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it + * (in milliseconds) + * @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another + * @returns {Promise} Resolves upon the message being sent + * @see {@link ShardingManager#respawnAll} + */ + respawnAll(shardDelay = 5000, respawnDelay = 500, waitForReady = true) { + return this.send({ _sRespawnAll: { shardDelay, respawnDelay, waitForReady } }); + } + /** * Handles an IPC message. * @param {*} message Message received diff --git a/src/sharding/ShardingManager.js b/src/sharding/ShardingManager.js index 7a18898be..9596ead2e 100644 --- a/src/sharding/ShardingManager.js +++ b/src/sharding/ShardingManager.js @@ -7,9 +7,12 @@ const Util = require('../util/Util'); const { Error, TypeError, RangeError } = require('../errors'); /** - * This is a utility class that can be used to help you spawn shards of your client. Each shard is completely separate - * from the other. The Shard Manager takes a path to a file and spawns it under the specified amount of shards safely. - * If you do not select an amount of shards, the manager will automatically decide the best amount. + * This is a utility class that makes multi-process sharding of a bot an easy and painless experience. + * It works by spawning a self-contained {@link ChildProcess} for each individual shard, each containing its own + * instance of your bot's {@link Client}. They all have a line of communication with the master process, and there are + * several useful methods that utilise it in order to simplify tasks that are normally difficult with sharding. It can + * spawn a specific number of shards or the amount that Discord suggests for the bot, and takes a path to your main bot + * script to launch for each one. * @extends {EventEmitter} */ class ShardingManager extends EventEmitter { @@ -82,33 +85,33 @@ class ShardingManager extends EventEmitter { /** * Spawns a single shard. - * @param {number} id The ID of the shard to spawn. **This is usually not necessary** - * @returns {Promise} + * @param {number} [id=this.shards.size] ID of the shard to spawn - + * **This is usually not necessary to manually specify.** + * @returns {Shard} */ createShard(id = this.shards.size) { const shard = new Shard(this, id, this.shardArgs); this.shards.set(id, shard); /** - * Emitted upon launching a shard. - * @event ShardingManager#launch - * @param {Shard} shard Shard that was launched + * Emitted upon creating a shard. + * @event ShardingManager#shardCreate + * @param {Shard} shard Shard that was created */ - this.emit('launch', shard); - return Promise.resolve(shard); + this.emit('shardCreate', shard); + return shard; } /** * Spawns multiple shards. * @param {number} [amount=this.totalShards] Number of shards to spawn - * @param {number} [delay=7500] How long to wait in between spawning each shard (in milliseconds) + * @param {number} [delay=5500] How long to wait in between spawning each shard (in milliseconds) + * @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another * @returns {Promise>} */ - spawn(amount = this.totalShards, delay = 7500) { + async spawn(amount = this.totalShards, delay = 5500, waitForReady = true) { + // Obtain/verify the number of shards to spawn if (amount === 'auto') { - return Util.fetchRecommendedShards(this.token).then(count => { - this.totalShards = count; - return this._spawn(count, delay); - }); + amount = await Util.fetchRecommendedShards(this.token); } else { if (typeof amount !== 'number' || isNaN(amount)) { throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.'); @@ -117,41 +120,22 @@ class ShardingManager extends EventEmitter { if (amount !== Math.floor(amount)) { throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.'); } - return this._spawn(amount, delay); } - } - /** - * Actually spawns shards, unlike that poser above >:( - * @param {number} amount Number of shards to spawn - * @param {number} delay How long to wait in between spawning each shard (in milliseconds) - * @returns {Promise>} - * @private - */ - _spawn(amount, delay) { - return new Promise(resolve => { - if (this.shards.size >= amount) throw new Error('SHARDING_ALREADY_SPAWNED', this.shards.size); - this.totalShards = amount; + // Make sure this many shards haven't already been spawned + if (this.shards.size >= amount) throw new Error('SHARDING_ALREADY_SPAWNED', this.shards.size); + this.totalShards = amount; - this.createShard(); - if (this.shards.size >= this.totalShards) { - resolve(this.shards); - return; - } + // Spawn the shards + for (let s = 1; s <= amount; s++) { + const promises = []; + const shard = this.createShard(); + promises.push(shard.spawn(waitForReady)); + if (delay > 0 && s !== amount) promises.push(Util.delayFor(delay)); + await Promise.all(promises); // eslint-disable-line no-await-in-loop + } - if (delay <= 0) { - while (this.shards.size < this.totalShards) this.createShard(); - resolve(this.shards); - } else { - const interval = setInterval(() => { - this.createShard(); - if (this.shards.size >= this.totalShards) { - clearInterval(interval); - resolve(this.shards); - } - }, delay); - } - }); + return this.shards; } /** @@ -166,9 +150,9 @@ class ShardingManager extends EventEmitter { } /** - * Evaluates a script on all shards, in the context of the Clients. + * Evaluates a script on all shards, in the context of the {@link Client}s. * @param {string} script JavaScript to run on each shard - * @returns {Promise} Results of the script execution + * @returns {Promise>} Results of the script execution */ broadcastEval(script) { const promises = []; @@ -179,7 +163,7 @@ class ShardingManager extends EventEmitter { /** * Fetches a client property value of each shard. * @param {string} prop Name of the client property to get, using periods for nesting - * @returns {Promise} + * @returns {Promise>} * @example * manager.fetchClientValues('guilds.size') * .then(results => { @@ -194,6 +178,24 @@ class ShardingManager extends EventEmitter { for (const shard of this.shards.values()) promises.push(shard.fetchClientValue(prop)); return Promise.all(promises); } + + /** + * Kills all running shards and respawns them. + * @param {number} [shardDelay=5000] How long to wait between shards (in milliseconds) + * @param {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it + * (in milliseconds) + * @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another + * @returns {Promise>} + */ + async respawnAll(shardDelay = 5000, respawnDelay = 500, waitForReady = true) { + let s = 0; + for (const shard of this.shards) { + const promises = [shard.respawn(respawnDelay, waitForReady)]; + if (++s < this.shards.size && shardDelay > 0) promises.push(Util.delayFor(shardDelay)); + await Promise.all(promises); // eslint-disable-line no-await-in-loop + } + return this.shards; + } } module.exports = ShardingManager; diff --git a/src/stores/DataStore.js b/src/stores/DataStore.js index 398910d50..c4256dfe2 100644 --- a/src/stores/DataStore.js +++ b/src/stores/DataStore.js @@ -1,4 +1,5 @@ const Collection = require('../util/Collection'); +let Structures; /** * Manages the creation, retrieval and deletion of a specific data model. @@ -7,8 +8,9 @@ const Collection = require('../util/Collection'); class DataStore extends Collection { constructor(client, iterable, holds) { super(); + if (!Structures) Structures = require('../util/Structures'); Object.defineProperty(this, 'client', { value: client }); - Object.defineProperty(this, 'holds', { value: holds }); + Object.defineProperty(this, 'holds', { value: Structures.get(holds.name) || holds }); if (iterable) for (const item of iterable) this.create(item); } diff --git a/src/stores/ReactionUserStore.js b/src/stores/ReactionUserStore.js new file mode 100644 index 000000000..b3c3ec012 --- /dev/null +++ b/src/stores/ReactionUserStore.js @@ -0,0 +1,33 @@ +const DataStore = require('./DataStore'); +/** + * A data store to store User models who reacted to a MessageReaction. + * @extends {DataStore} + */ +class ReactionUserStore extends DataStore { + constructor(client, iterable, reaction) { + super(client, iterable, require('../structures/User')); + this.reaction = reaction; + } + + /** + * Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their IDs. + * @param {Object} [options] Options for fetching the users + * @param {number} [options.limit=100] The maximum amount of users to fetch, defaults to 100 + * @param {Snowflake} [options.before] Limit fetching users to those with an id lower than the supplied id + * @param {Snowflake} [options.after] Limit fetching users to those with an id greater than the supplied id + * @returns {Promise>} + */ + async fetch({ limit = 100, after, before } = {}) { + const message = this.reaction.message; + const users = await this.client.api.channels[message.channel.id].messages[message.id] + .reactions[this.reaction.emoji.identifier] + .get({ query: { limit, before, after } }); + for (const rawUser of users) { + const user = this.client.users.create(rawUser); + this.set(user.id, user); + } + return this; + } +} + +module.exports = ReactionUserStore; diff --git a/src/structures/CategoryChannel.js b/src/structures/CategoryChannel.js index 2c063f73d..d7121a32b 100644 --- a/src/structures/CategoryChannel.js +++ b/src/structures/CategoryChannel.js @@ -6,7 +6,7 @@ const GuildChannel = require('./GuildChannel'); */ class CategoryChannel extends GuildChannel { /** - * The channels that are part of this category + * Channels that are part of this category * @type {?Collection} * @readonly */ diff --git a/src/structures/Channel.js b/src/structures/Channel.js index 33b851f09..04867b118 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -66,32 +66,37 @@ class Channel extends Base { } static create(client, data, guild) { - const DMChannel = require('./DMChannel'); - const GroupDMChannel = require('./GroupDMChannel'); - const TextChannel = require('./TextChannel'); - const VoiceChannel = require('./VoiceChannel'); - const CategoryChannel = require('./CategoryChannel'); - const GuildChannel = require('./GuildChannel'); + const Structures = require('../util/Structures'); let channel; if (data.type === ChannelTypes.DM) { + const DMChannel = Structures.get('DMChannel'); channel = new DMChannel(client, data); } else if (data.type === ChannelTypes.GROUP) { + const GroupDMChannel = Structures.get('GroupDMChannel'); channel = new GroupDMChannel(client, data); } else { guild = guild || client.guilds.get(data.guild_id); if (guild) { switch (data.type) { - case ChannelTypes.TEXT: + case ChannelTypes.TEXT: { + const TextChannel = Structures.get('TextChannel'); channel = new TextChannel(guild, data); break; - case ChannelTypes.VOICE: + } + case ChannelTypes.VOICE: { + const VoiceChannel = Structures.get('VoiceChannel'); channel = new VoiceChannel(guild, data); break; - case ChannelTypes.CATEGORY: + } + case ChannelTypes.CATEGORY: { + const CategoryChannel = Structures.get('CategoryChannel'); channel = new CategoryChannel(guild, data); break; - default: + } + default: { + const GuildChannel = Structures.get('GuildChannel'); channel = new GuildChannel(guild, data); + } } guild.channels.set(channel.id, channel); } diff --git a/src/structures/ClientApplication.js b/src/structures/ClientApplication.js index ccca3f7e0..073fce7ba 100644 --- a/src/structures/ClientApplication.js +++ b/src/structures/ClientApplication.js @@ -150,11 +150,12 @@ class ClientApplication extends Base { * @returns {Promise} */ fetchAssets() { - return this.client.api.applications(this.id).assets.get() + const types = Object.keys(ClientApplicationAssetTypes); + return this.client.api.oauth2.applications(this.id).assets.get() .then(assets => assets.map(a => ({ id: a.id, name: a.name, - type: Object.keys(ClientApplicationAssetTypes)[a.type - 1], + type: types[a.type - 1], }))); } @@ -167,7 +168,7 @@ class ClientApplication extends Base { */ createAsset(name, data, type) { return DataResolver.resolveBase64(data).then(b64 => - this.client.api.applications(this.id).assets.post({ data: { + this.client.api.oauth2.applications(this.id).assets.post({ data: { name, data: b64, type: ClientApplicationAssetTypes[type.toUpperCase()], diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js index ce71d9bcf..b0b3452d4 100644 --- a/src/structures/ClientUser.js +++ b/src/structures/ClientUser.js @@ -1,4 +1,4 @@ -const User = require('./User'); +const Structures = require('../util/Structures'); const Collection = require('../util/Collection'); const ClientUserSettings = require('./ClientUserSettings'); const ClientUserGuildSettings = require('./ClientUserGuildSettings'); @@ -11,7 +11,7 @@ const Guild = require('./Guild'); * Represents the logged in client's Discord user. * @extends {User} */ -class ClientUser extends User { +class ClientUser extends Structures.get('User') { _patch(data) { super._patch(data); @@ -248,7 +248,7 @@ class ClientUser extends User { /** * Fetches messages that mentioned the client's user. * This is only available when using a user account. - * @param {Object} [options] Options for the fetch + * @param {Object} [options={}] Options for the fetch * @param {number} [options.limit=25] Maximum number of mentions to retrieve * @param {boolean} [options.roles=true] Whether to include role mentions * @param {boolean} [options.everyone=true] Whether to include everyone/here mentions diff --git a/src/structures/Guild.js b/src/structures/Guild.js index 73adbe1df..307e5eda2 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -114,8 +114,18 @@ class Guild extends Base { this.large = Boolean('large' in data ? data.large : this.large); /** - * An array of guild features - * @type {string[]} + * An array of enabled guild features, here are the possible values: + * * INVITE_SPLASH + * * MORE_EMOJI + * * VERIFIED + * * VIP_REGIONS + * * VANITY_URL + * @typedef {string} Features + */ + + /** + * An array of guild features partnered guilds have enabled + * @type {Features[]} */ this.features = data.features; @@ -311,7 +321,7 @@ class Guild extends Base { /** * System channel for this guild - * @type {?GuildChannel} + * @type {?TextChannel} * @readonly */ get systemChannel() { @@ -431,10 +441,16 @@ class Guild extends Base { return this.members.resolve(user); } + /** + * An object containing information about a guild member's ban. + * @typedef {Object} BanInfo + * @property {User} user User that was banned + * @property {?string} reason Reason the user was banned + */ + /** * Fetches a collection of banned users in this guild. - * The returned collection contains user objects keyed under `user` and reasons keyed under `reason`. - * @returns {Promise>} + * @returns {Promise>} */ fetchBans() { return this.client.api.guilds(this.id).bans.get().then(bans => @@ -496,7 +512,7 @@ class Guild extends Base { * @param {Snowflake|GuildAuditLogsEntry} [options.after] Limit to entries from after specified entry * @param {number} [options.limit] Limit number of entries * @param {UserResolvable} [options.user] Only show entries involving this user - * @param {ActionType|number} [options.type] Only show entries involving this action type + * @param {AuditLogAction|number} [options.type] Only show entries involving this action type * @returns {Promise} */ fetchAuditLogs(options = {}) { @@ -528,7 +544,9 @@ class Guild extends Base { * @returns {Promise} */ addMember(user, options) { - if (this.members.has(user.id)) return Promise.resolve(this.members.get(user.id)); + user = this.client.users.resolveID(user); + if (!user) return Promise.reject(new TypeError('INVALID_TYPE', 'user', 'UserResolvable')); + if (this.members.has(user)) return Promise.resolve(this.members.get(user)); options.access_token = options.accessToken; if (options.roles) { const roles = []; @@ -541,7 +559,7 @@ class Guild extends Base { roles.push(role.id); } } - return this.client.api.guilds(this.id).members(user.id).put({ data: options }) + return this.client.api.guilds(this.id).members(user).put({ data: options }) .then(data => this.members.create(data)); } @@ -794,6 +812,7 @@ class Guild extends Base { * @returns {Promise} */ allowDMs(allow) { + if (this.client.user.bot) return Promise.reject(new Error('FEATURE_USER_ONLY')); const settings = this.client.user.settings; if (allow) return settings.removeRestrictedGuild(this); else return settings.addRestrictedGuild(this); @@ -802,11 +821,10 @@ class Guild extends Base { /** * Bans a user from the guild. * @param {UserResolvable} user The user to ban - * @param {Object} [options] Ban options. If a number, the number of days to delete messages for, if a - * string, the ban reason. Supplying an object allows you to do both. + * @param {Object} [options] Options for the ban * @param {number} [options.days=0] Number of days of messages to delete * @param {string} [options.reason] Reason for banning - * @returns {Promise} Result object will be resolved as specifically as possible. + * @returns {Promise} Result object will be resolved as specifically as possible. * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot * be resolved, the user ID will be the result. * @example @@ -1054,8 +1072,7 @@ class Guild extends Base { .then(emoji => this.client.actions.GuildEmojiCreate.handle(this, emoji).emoji); } - return DataResolver.resolveImage(attachment) - .then(image => this.createEmoji(image, name, { roles, reason })); + return DataResolver.resolveImage(attachment).then(image => this.createEmoji(image, name, { roles, reason })); } /** @@ -1133,6 +1150,34 @@ class Guild extends Base { return this.name; } + /** + * Creates a collection of this guild's roles, sorted by their position and IDs. + * @returns {Collection} + * @private + */ + _sortedRoles() { + return Util.discordSort(this.roles); + } + + /** + * Creates a collection of this guild's or a specific category's channels, sorted by their position and IDs. + * @param {GuildChannel} [channel] Category to get the channels of + * @returns {Collection} + * @private + */ + _sortedChannels(channel) { + const category = channel.type === ChannelTypes.CATEGORY; + return Util.discordSort(this.channels.filter(c => + c.type === channel.type && (category || c.parent === channel.parent) + )); + } + + /** + * Handles a user speaking update in a voice channel. + * @param {Snowflake} user ID of the user that the update is for + * @param {boolean} speaking Whether the user is speaking + * @private + */ _memberSpeakUpdate(user, speaking) { const member = this.members.get(user); if (member && member.speaking !== speaking) { @@ -1146,23 +1191,15 @@ class Guild extends Base { this.client.emit(Events.GUILD_MEMBER_SPEAKING, member, speaking); } } - - _sortedRoles() { - return Util.discordSort(this.roles); - } - - _sortedChannels(channel) { - const category = channel.type === ChannelTypes.CATEGORY; - return Util.discordSort(this.channels.filter(c => - c.type === channel.type && (category || c.parent === channel.parent))); - } } +// TODO: Document this thing class VoiceStateCollection extends Collection { constructor(guild) { super(); this.guild = guild; } + set(id, voiceState) { const member = this.guild.members.get(id); if (member) { diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js index 896e2bdf8..a84ad0847 100644 --- a/src/structures/GuildChannel.js +++ b/src/structures/GuildChannel.js @@ -293,7 +293,7 @@ class GuildChannel extends Channel { topic: data.topic, nsfw: data.nsfw, bitrate: data.bitrate || (this.bitrate ? this.bitrate * 1000 : undefined), - user_limit: data.userLimit != null ? data.userLimit : this.userLimit, // eslint-disable-line eqeqeq + user_limit: typeof data.userLimit !== 'undefined' ? data.userLimit : this.userLimit, parent_id: data.parentID, lock_permissions: data.lockPermissions, permission_overwrites: data.permissionOverwrites, diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index 7ab2597fd..21f84ec98 100644 --- a/src/structures/GuildMember.js +++ b/src/structures/GuildMember.js @@ -77,36 +77,42 @@ class GuildMember extends Base { /** * Whether this member is deafened server-wide * @type {boolean} + * @readonly */ get serverDeaf() { return this.voiceState.deaf; } /** * Whether this member is muted server-wide * @type {boolean} + * @readonly */ get serverMute() { return this.voiceState.mute; } /** * Whether this member is self-muted * @type {boolean} + * @readonly */ get selfMute() { return this.voiceState.self_mute; } /** * Whether this member is self-deafened * @type {boolean} + * @readonly */ get selfDeaf() { return this.voiceState.self_deaf; } /** * The voice session ID of this member (if any) * @type {?Snowflake} + * @readonly */ get voiceSessionID() { return this.voiceState.session_id; } /** * The voice channel ID of this member, (if any) * @type {?Snowflake} + * @readonly */ get voiceChannelID() { return this.voiceState.channel_id; } @@ -515,8 +521,7 @@ class GuildMember extends Base { /** * Bans this guild member. - * @param {Object} [options] Ban options. If a number, the number of days to delete messages for, if a - * string, the ban reason. Supplying an object allows you to do both. + * @param {Object} [options] Options for the ban * @param {number} [options.days=0] Number of days of messages to delete * @param {string} [options.reason] Reason for banning * @returns {Promise} diff --git a/src/structures/Message.js b/src/structures/Message.js index ee4c9b662..a409e411a 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -8,9 +8,9 @@ const Collection = require('../util/Collection'); const ReactionStore = require('../stores/ReactionStore'); const { MessageTypes } = require('../util/Constants'); const Permissions = require('../util/Permissions'); -const GuildMember = require('./GuildMember'); const Base = require('./Base'); const { Error, TypeError } = require('../errors'); +const { createMessage } = require('./shared'); /** * Represents a message on Discord. @@ -368,41 +368,22 @@ class Message extends Base { * .then(msg => console.log(`Updated the content of a message from ${msg.author}`)) * .catch(console.error); */ - edit(content, options) { + async edit(content, options) { if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; - content = ''; + content = null; } else if (!options) { options = {}; } - if (options instanceof Embed) options = { embed: options }; + if (!options.content) options.content = content; - if (typeof options.content !== 'undefined') content = options.content; - - if (typeof content !== 'undefined') content = Util.resolveString(content); - - let { embed, code, reply } = options; - - if (embed) embed = new Embed(embed)._apiTransform(); - - // Wrap everything in a code block - if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) { - content = Util.escapeMarkdown(Util.resolveString(content), true); - content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``; - } - - // Add the reply prefix - if (reply && this.channel.type !== 'dm') { - const id = this.client.users.resolveID(reply); - const mention = `<@${reply instanceof GuildMember && reply.nickname ? '!' : ''}${id}>`; - content = `${mention}${content ? `, ${content}` : ''}`; - } + const { data, files } = await createMessage(this, options); return this.client.api.channels[this.channel.id].messages[this.id] - .patch({ data: { content, embed } }) - .then(data => { + .patch({ data, files }) + .then(d => { const clone = this._clone(); - clone._patch(data); + clone._patch(d); return clone; }); } diff --git a/src/structures/MessageMentions.js b/src/structures/MessageMentions.js index 1b49c5ea8..ee4a0e69d 100644 --- a/src/structures/MessageMentions.js +++ b/src/structures/MessageMentions.js @@ -117,19 +117,27 @@ class MessageMentions { } /** - * Check if a user is mentioned. + * Checks if a user, guild member, role, or channel is mentioned. * Takes into account user mentions, role mentions, and @everyone/@here mentions. * @param {UserResolvable|GuildMember|Role|GuildChannel} data User/GuildMember/Role/Channel to check - * @param {boolean} [strict=true] If role mentions and everyone/here mentions should be included + * @param {Object} [options] Options + * @param {boolean} [options.ignoreDirect=false] - Whether to ignore direct mentions to the item + * @param {boolean} [options.ignoreRoles=false] - Whether to ignore role mentions to a guild member + * @param {boolean} [options.ignoreEveryone=false] - Whether to ignore everyone/here mentions * @returns {boolean} */ - has(data, strict = true) { - if (strict && this.everyone) return true; - if (strict && data instanceof GuildMember) { + has(data, { ignoreDirect = false, ignoreRoles = false, ignoreEveryone = false } = {}) { + if (!ignoreEveryone && this.everyone) return true; + if (!ignoreRoles && data instanceof GuildMember) { for (const role of this.roles.values()) if (data.roles.has(role.id)) return true; } - const id = data.id || data; - return this.users.has(id) || this.channels.has(id) || this.roles.has(id); + + if (!ignoreDirect) { + const id = data.id || data; + return this.users.has(id) || this.channels.has(id) || this.roles.has(id); + } + + return false; } } diff --git a/src/structures/MessageReaction.js b/src/structures/MessageReaction.js index 2b273f84b..28a320a63 100644 --- a/src/structures/MessageReaction.js +++ b/src/structures/MessageReaction.js @@ -1,6 +1,6 @@ -const Collection = require('../util/Collection'); const Emoji = require('./Emoji'); const ReactionEmoji = require('./ReactionEmoji'); +const ReactionUserStore = require('../stores/ReactionUserStore'); const { Error } = require('../errors'); /** @@ -28,9 +28,9 @@ class MessageReaction { /** * The users that have given this reaction, mapped by their ID - * @type {Collection} + * @type {ReactionUserStore} */ - this.users = new Collection(); + this.users = new ReactionUserStore(client, undefined, this); this._emoji = new ReactionEmoji(this, data.emoji.name, data.emoji.id); } @@ -77,26 +77,6 @@ class MessageReaction { ); } - /** - * Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their IDs. - * @param {Object} [options] Options for fetching the users - * @param {number} [options.limit=100] The maximum amount of users to fetch, defaults to 100 - * @param {Snowflake} [options.after] Limit fetching users to those with an id greater than the supplied id - * @returns {Promise>} - */ - async fetchUsers({ limit = 100, after } = {}) { - const message = this.message; - const users = await message.client.api.channels[message.channel.id].messages[message.id] - .reactions[this.emoji.identifier] - .get({ query: { limit, after } }); - for (const rawUser of users) { - const user = message.client.users.create(rawUser); - this.users.set(user.id, user); - } - this.count = this.users.size; - return this.users; - } - _add(user) { if (!this.users.has(user.id)) { this.users.set(user.id, user); diff --git a/src/structures/ReactionCollector.js b/src/structures/ReactionCollector.js index 1ebab746c..60bdabc48 100644 --- a/src/structures/ReactionCollector.js +++ b/src/structures/ReactionCollector.js @@ -83,7 +83,17 @@ class ReactionCollector extends Collector { * @returns {?Snowflake|string} */ dispose(reaction) { - return reaction.message.id === this.message.id && !reaction.count ? ReactionCollector.key(reaction) : null; + if (reaction.message.id !== this.message.id) return null; + + /** + * Emitted whenever a reaction is removed from a message. Will emit on all reaction removals, + * as opposed to {@link Collector#dispose} which will only be emitted when the entire reaction + * is removed. + * @event ReactionCollector#remove + * @param {MessageReaction} reaction The reaction that was removed + */ + if (this.collected.has(reaction)) this.emit('remove', reaction); + return reaction.count ? null : ReactionCollector.key(reaction); } /** diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index f1a5c6a11..7ee44745e 100644 --- a/src/structures/Webhook.js +++ b/src/structures/Webhook.js @@ -1,9 +1,5 @@ -const Util = require('../util/Util'); const DataResolver = require('../util/DataResolver'); -const Embed = require('./MessageEmbed'); -const MessageAttachment = require('./MessageAttachment'); -const MessageEmbed = require('./MessageEmbed'); -const { browser } = require('../util/Constants'); +const { createMessage } = require('./shared'); /** * Represents a webhook. @@ -98,115 +94,37 @@ class Webhook { * .catch(console.error); */ /* eslint-enable max-len */ - send(content, options) { // eslint-disable-line complexity + async send(content, options) { // eslint-disable-line complexity if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; - content = ''; + content = null; } else if (!options) { options = {}; } + if (!options.content) options.content = content; - if (options instanceof MessageAttachment) options = { files: [options.file] }; - if (options instanceof MessageEmbed) options = { embeds: [options] }; - if (options.embed) options = { embeds: [options.embed] }; + const { data, files } = await createMessage(this, options); - if (content instanceof Array || options instanceof Array) { - const which = content instanceof Array ? content : options; - const attachments = which.filter(item => item instanceof MessageAttachment); - const embeds = which.filter(item => item instanceof MessageEmbed); - if (attachments.length) options = { files: attachments }; - if (embeds.length) options = { embeds }; - if ((embeds.length || attachments.length) && content instanceof Array) content = ''; - } - - if (!options.username) options.username = this.name; - if (options.avatarURL) { - options.avatar_url = options.avatarURL; - options.avatarURL = null; - } - - if (content) { - content = Util.resolveString(content); - let { split, code, disableEveryone } = options; - if (split && typeof split !== 'object') split = {}; - if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) { - content = Util.escapeMarkdown(content, true); - content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``; - if (split) { - split.prepend = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n`; - split.append = '\n```'; - } + if (data.content instanceof Array) { + const messages = []; + for (let i = 0; i < data.content.length; i++) { + const opt = i === data.content.length - 1 ? { embeds: data.embeds, files } : {}; + Object.assign(opt, { avatarURL: data.avatar_url, content: data.content[i], username: data.username }); + // eslint-disable-next-line no-await-in-loop + const message = await this.send(data.content[i], opt); + messages.push(message); } - if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) { - content = content.replace(/@(everyone|here)/g, '@\u200b$1'); - } - - if (split) content = Util.splitMessage(content, split); - } - options.content = content; - - if (options.embeds) options.embeds = options.embeds.map(embed => new Embed(embed)._apiTransform()); - - if (options.files) { - for (let i = 0; i < options.files.length; i++) { - let file = options.files[i]; - if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file }; - if (!file.name) { - if (typeof file.attachment === 'string') { - file.name = Util.basename(file.attachment); - } else if (file.attachment && file.attachment.path) { - file.name = Util.basename(file.attachment.path); - } else if (file instanceof MessageAttachment) { - file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' }; - } else { - file.name = 'file.jpg'; - } - } else if (file instanceof MessageAttachment) { - file = file.file; - } - options.files[i] = file; - } - - return Promise.all(options.files.map(file => - DataResolver.resolveFile(file.attachment).then(resource => { - file.file = resource; - return file; - }) - )).then(files => this.client.api.webhooks(this.id, this.token).post({ - data: options, - query: { wait: true }, - files, - auth: false, - })); + return messages; } - if (content instanceof Array) { - return new Promise((resolve, reject) => { - const messages = []; - (function sendChunk() { - const opt = content.length ? null : { embeds: options.embeds, files: options.files }; - this.client.api.webhooks(this.id, this.token).post({ - data: { content: content.shift(), opt }, - query: { wait: true }, - auth: false, - }) - .then(message => { - messages.push(message); - if (content.length === 0) return resolve(messages); - return sendChunk.call(this); - }) - .catch(reject); - }.call(this)); - }); - } return this.client.api.webhooks(this.id, this.token).post({ - data: options, + data, files, query: { wait: true }, auth: false, - }).then(data => { - if (!this.client.channels) return data; - return this.client.channels.get(data.channel_id).messages.create(data, false); + }).then(d => { + if (!this.client.channels) return d; + return this.client.channels.get(d.channel_id).messages.create(d, false); }); } diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index ce997ae37..39aac0937 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -1,12 +1,7 @@ const MessageCollector = require('../MessageCollector'); const Shared = require('../shared'); -const Util = require('../../util/Util'); -const { browser } = require('../../util/Constants'); const Snowflake = require('../../util/Snowflake'); const Collection = require('../../util/Collection'); -const DataResolver = require('../../util/DataResolver'); -const MessageAttachment = require('../../structures/MessageAttachment'); -const MessageEmbed = require('../../structures/MessageEmbed'); const { RangeError, TypeError } = require('../../errors'); /** @@ -80,61 +75,12 @@ class TextBasedChannel { send(content, options) { // eslint-disable-line complexity if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; - content = ''; + content = null; } else if (!options) { options = {}; } - - if (options instanceof MessageEmbed) options = { embed: options }; - if (options instanceof MessageAttachment) options = { files: [options.file] }; - - if (content instanceof Array || options instanceof Array) { - const which = content instanceof Array ? content : options; - const attachments = which.filter(item => item instanceof MessageAttachment); - if (attachments.length) { - options = { files: attachments }; - if (content instanceof Array) content = ''; - } - } - if (!options.content) options.content = content; - if (options.embed && options.embed.files) { - if (options.files) options.files = options.files.concat(options.embed.files); - else options.files = options.embed.files; - } - - if (options.files) { - for (let i = 0; i < options.files.length; i++) { - let file = options.files[i]; - if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file }; - if (!file.name) { - if (typeof file.attachment === 'string') { - file.name = Util.basename(file.attachment); - } else if (file.attachment && file.attachment.path) { - file.name = Util.basename(file.attachment.path); - } else if (file instanceof MessageAttachment) { - file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' }; - } else { - file.name = 'file.jpg'; - } - } else if (file instanceof MessageAttachment) { - file = file.file; - } - options.files[i] = file; - } - - return Promise.all(options.files.map(file => - DataResolver.resolveFile(file.attachment).then(resource => { - file.file = resource; - return file; - }) - )).then(files => { - options.files = files; - return Shared.sendMessage(this, options); - }); - } - return Shared.sendMessage(this, options); } @@ -158,26 +104,45 @@ class TextBasedChannel { /** * Starts a typing indicator in the channel. - * @param {number} [count] The number of times startTyping should be considered to have been called + * @param {number} [count=1] The number of times startTyping should be considered to have been called + * @returns {Promise} Resolves once the bot stops typing gracefully, or rejects when an error occurs * @example - * // Start typing in a channel + * // Start typing in a channel, or increase the typing count by one * channel.startTyping(); + * @example + * // Start typing in a channel with a typing count of five, or set it to five + * channel.startTyping(5); */ startTyping(count) { if (typeof count !== 'undefined' && count < 1) throw new RangeError('TYPING_COUNT'); - if (!this.client.user._typing.has(this.id)) { - const endpoint = this.client.api.channels[this.id].typing; - this.client.user._typing.set(this.id, { - count: count || 1, - interval: this.client.setInterval(() => { - endpoint.post(); - }, 9000), - }); - endpoint.post(); - } else { + if (this.client.user._typing.has(this.id)) { const entry = this.client.user._typing.get(this.id); entry.count = count || entry.count + 1; + return entry.promise; } + + const entry = {}; + entry.promise = new Promise((resolve, reject) => { + const endpoint = this.client.api.channels[this.id].typing; + Object.assign(entry, { + count: count || 1, + interval: this.client.setInterval(() => { + endpoint.post().catch(error => { + this.client.clearInterval(entry.interval); + this.client.user._typing.delete(this.id); + reject(error); + }); + }, 9000), + resolve, + }); + endpoint.post().catch(error => { + this.client.clearInterval(entry.interval); + this.client.user._typing.delete(this.id); + reject(error); + }); + this.client.user._typing.set(this.id, entry); + }); + return entry.promise; } /** @@ -186,10 +151,10 @@ class TextBasedChannel { * It can take a few seconds for the client user to stop typing. * @param {boolean} [force=false] Whether or not to reset the call count and force the indicator to stop * @example - * // Stop typing in a channel + * // Reduce the typing count by one and stop typing if it reached 0 * channel.stopTyping(); * @example - * // Force typing to fully stop in a channel + * // Force typing to fully stop regardless of typing count * channel.stopTyping(true); */ stopTyping(force = false) { @@ -199,6 +164,7 @@ class TextBasedChannel { if (entry.count <= 0 || force) { this.client.clearInterval(entry.interval); this.client.user._typing.delete(this.id); + entry.resolve(); } } } diff --git a/src/structures/shared/CreateMessage.js b/src/structures/shared/CreateMessage.js new file mode 100644 index 000000000..5abf0799e --- /dev/null +++ b/src/structures/shared/CreateMessage.js @@ -0,0 +1,108 @@ +const Embed = require('../MessageEmbed'); +const DataResolver = require('../../util/DataResolver'); +const MessageEmbed = require('../MessageEmbed'); +const MessageAttachment = require('../MessageAttachment'); +const { browser } = require('../../util/Constants'); +const Util = require('../../util/Util'); + +// eslint-disable-next-line complexity +module.exports = async function createMessage(channel, options) { + const User = require('../User'); + const GuildMember = require('../GuildMember'); + const Webhook = require('../Webhook'); + const WebhookClient = require('../../client/WebhookClient'); + + const webhook = channel instanceof Webhook || channel instanceof WebhookClient; + + if (typeof options.nonce !== 'undefined') { + options.nonce = parseInt(options.nonce); + if (isNaN(options.nonce) || options.nonce < 0) throw new RangeError('MESSAGE_NONCE_TYPE'); + } + + if (options instanceof MessageEmbed) options = webhook ? { embeds: [options] } : { embed: options }; + if (options instanceof MessageAttachment) options = { files: [options.file] }; + + if (options.reply && !(channel instanceof User || channel instanceof GuildMember) && channel.type !== 'dm') { + const id = channel.client.users.resolveID(options.reply); + const mention = `<@${options.reply instanceof GuildMember && options.reply.nickname ? '!' : ''}${id}>`; + if (options.split) options.split.prepend = `${mention}, ${options.split.prepend || ''}`; + options.content = `${mention}${typeof options.content !== 'undefined' ? `, ${options.content}` : ''}`; + } + + if (options.content) { + options.content = Util.resolveString(options.content); + if (options.split && typeof options.split !== 'object') options.split = {}; + // Wrap everything in a code block + if (typeof options.code !== 'undefined' && (typeof options.code !== 'boolean' || options.code === true)) { + options.content = Util.escapeMarkdown(options.content, true); + options.content = + `\`\`\`${typeof options.code !== 'boolean' ? options.code || '' : ''}\n${options.content}\n\`\`\``; + if (options.split) { + options.split.prepend = `\`\`\`${typeof options.code !== 'boolean' ? options.code || '' : ''}\n`; + options.split.append = '\n```'; + } + } + + // Add zero-width spaces to @everyone/@here + if (options.disableEveryone || + (typeof options.disableEveryone === 'undefined' && channel.client.options.disableEveryone)) { + options.content = options.content.replace(/@(everyone|here)/g, '@\u200b$1'); + } + + if (options.split) options.content = Util.splitMessage(options.content, options.split); + } + + if (options.embed && options.embed.files) { + if (options.files) options.files = options.files.concat(options.embed.files); + else options.files = options.embed.files; + } + + if (options.embed && webhook) options.embeds = [new Embed(options.embed)._apiTransform()]; + else if (options.embed) options.embed = new Embed(options.embed)._apiTransform(); + else if (options.embeds) options.embeds = options.embeds.map(e => new Embed(e)._apiTransform()); + + let files; + + if (options.files) { + for (let i = 0; i < options.files.length; i++) { + let file = options.files[i]; + if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file }; + if (!file.name) { + if (typeof file.attachment === 'string') { + file.name = Util.basename(file.attachment); + } else if (file.attachment && file.attachment.path) { + file.name = Util.basename(file.attachment.path); + } else if (file instanceof MessageAttachment) { + file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' }; + } else { + file.name = 'file.jpg'; + } + } else if (file instanceof MessageAttachment) { + file = file.file; + } + options.files[i] = file; + } + + files = await Promise.all(options.files.map(file => + DataResolver.resolveFile(file.attachment).then(resource => { + file.file = resource; + return file; + }) + )); + } + + if (webhook) { + if (!options.username) options.username = this.name; + if (options.avatarURL) options.avatar_url = options.avatarURL; + } + + return { data: { + content: options.content, + tts: options.tts, + nonce: options.nonce, + embed: options.embed, + embeds: options.embeds, + username: options.username, + avatar_url: options.avatar_url, + }, files }; +}; diff --git a/src/structures/shared/Search.js b/src/structures/shared/Search.js index a852a5086..3adca7fcc 100644 --- a/src/structures/shared/Search.js +++ b/src/structures/shared/Search.js @@ -1,4 +1,4 @@ -const long = require('long'); +const Util = require('../../util/Util'); const { TypeError } = require('../../errors'); /** @@ -40,17 +40,17 @@ module.exports = function search(target, options) { if (typeof options === 'string') options = { content: options }; if (options.before) { if (!(options.before instanceof Date)) options.before = new Date(options.before); - options.maxID = long.fromNumber(options.before.getTime() - 14200704e5).shiftLeft(22).toString(); + options.maxID = Util.binaryToID((options.before.getTime() - 14200704e5).toString(2) + '0'.repeat(22)); } if (options.after) { if (!(options.after instanceof Date)) options.after = new Date(options.after); - options.minID = long.fromNumber(options.after.getTime() - 14200704e5).shiftLeft(22).toString(); + options.minID = Util.binaryToID((options.after.getTime() - 14200704e5).toString(2) + '0'.repeat(22)); } if (options.during) { if (!(options.during instanceof Date)) options.during = new Date(options.during); const t = options.during.getTime() - 14200704e5; - options.minID = long.fromNumber(t).shiftLeft(22).toString(); - options.maxID = long.fromNumber(t + 864e5).shiftLeft(22).toString(); + options.minID = Util.binaryToID(t.toString(2) + '0'.repeat(22)); + options.maxID = Util.binaryToID((t + 864e5).toString(2) + '0'.repeat(22)); } if (options.channel) options.channel = target.client.channels.resolveID(options.channel); if (options.author) options.author = target.client.users.resolveID(options.author); diff --git a/src/structures/shared/SendMessage.js b/src/structures/shared/SendMessage.js index 560ece728..95ea49ca3 100644 --- a/src/structures/shared/SendMessage.js +++ b/src/structures/shared/SendMessage.js @@ -1,65 +1,23 @@ -const Util = require('../../util/Util'); -const Embed = require('../MessageEmbed'); -const { RangeError } = require('../../errors'); +const createMessage = require('./CreateMessage'); -module.exports = function sendMessage(channel, options) { // eslint-disable-line complexity +module.exports = async function sendMessage(channel, options) { // eslint-disable-line complexity const User = require('../User'); const GuildMember = require('../GuildMember'); if (channel instanceof User || channel instanceof GuildMember) return channel.createDM().then(dm => dm.send(options)); - let { content, nonce, reply, code, disableEveryone, tts, embed, files, split } = options; - if (embed) embed = new Embed(embed)._apiTransform(); + const { data, files } = await createMessage(channel, options); - if (typeof nonce !== 'undefined') { - nonce = parseInt(nonce); - if (isNaN(nonce) || nonce < 0) throw new RangeError('MESSAGE_NONCE_TYPE'); - } - - // Add the reply prefix - if (reply && !(channel instanceof User || channel instanceof GuildMember) && channel.type !== 'dm') { - const id = channel.client.users.resolveID(reply); - const mention = `<@${reply instanceof GuildMember && reply.nickname ? '!' : ''}${id}>`; - if (split) split.prepend = `${mention}, ${split.prepend || ''}`; - content = `${mention}${typeof content !== 'undefined' ? `, ${content}` : ''}`; - } - - if (content) { - content = Util.resolveString(content); - if (split && typeof split !== 'object') split = {}; - // Wrap everything in a code block - if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) { - content = Util.escapeMarkdown(content, true); - content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``; - if (split) { - split.prepend = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n`; - split.append = '\n```'; - } + if (data.content instanceof Array) { + const messages = []; + for (let i = 0; i < data.content.length; i++) { + const opt = i === data.content.length - 1 ? { tts: data.tts, embed: data.embed, files } : { tts: data.tts }; + // eslint-disable-next-line no-await-in-loop + const message = await channel.send(data.content[i], opt); + messages.push(message); } - - // Add zero-width spaces to @everyone/@here - if (disableEveryone || (typeof disableEveryone === 'undefined' && channel.client.options.disableEveryone)) { - content = content.replace(/@(everyone|here)/g, '@\u200b$1'); - } - - if (split) content = Util.splitMessage(content, split); + return messages; } - if (content instanceof Array) { - return new Promise((resolve, reject) => { - const messages = []; - (function sendChunk() { - const opt = content.length ? { tts } : { tts, embed, files }; - channel.send(content.shift(), opt).then(message => { - messages.push(message); - if (content.length === 0) return resolve(messages); - return sendChunk(); - }).catch(reject); - }()); - }); - } - - return channel.client.api.channels[channel.id].messages.post({ - data: { content, tts, nonce, embed }, - files, - }).then(data => channel.client.actions.MessageCreate.handle(data).message); + return channel.client.api.channels[channel.id].messages.post({ data, files }) + .then(d => channel.client.actions.MessageCreate.handle(d).message); }; diff --git a/src/structures/shared/index.js b/src/structures/shared/index.js index 67eed7f83..67a09646b 100644 --- a/src/structures/shared/index.js +++ b/src/structures/shared/index.js @@ -1,4 +1,5 @@ module.exports = { search: require('./Search'), sendMessage: require('./SendMessage'), + createMessage: require('./CreateMessage'), }; diff --git a/src/util/Constants.js b/src/util/Constants.js index 7a0b262d3..efeeb29fa 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -52,7 +52,7 @@ exports.DefaultOptions = { * WebSocket options (these are left as snake_case to match the API) * @typedef {Object} WebsocketOptions * @property {number} [large_threshold=250] Number of members in a guild to be considered large - * @property {boolean} [compress=true] Whether to compress data sent on the connection + * @property {boolean} [compress=false] Whether to compress data sent on the connection * (defaults to `false` for browsers) */ ws: { diff --git a/src/util/Snowflake.js b/src/util/Snowflake.js index f16839108..27f9f7440 100644 --- a/src/util/Snowflake.js +++ b/src/util/Snowflake.js @@ -1,4 +1,4 @@ -const Long = require('long'); +const Util = require('../util/Util'); // Discord epoch (2015-01-01T00:00:00.000Z) const EPOCH = 1420070400000; @@ -31,8 +31,9 @@ class SnowflakeUtil { */ static generate() { if (INCREMENT >= 4095) INCREMENT = 0; - const BINARY = `${pad((Date.now() - EPOCH).toString(2), 42)}0000100000${pad((INCREMENT++).toString(2), 12)}`; - return Long.fromString(BINARY, 2).toString(); + // eslint-disable-next-line max-len + const BINARY = `${(Date.now() - EPOCH).toString(2).padStart(42, '0')}0000100000${(INCREMENT++).toString(2).padStart(12, '0')}`; + return Util.binaryToID(BINARY); } /** @@ -52,7 +53,7 @@ class SnowflakeUtil { * @returns {DeconstructedSnowflake} Deconstructed snowflake */ static deconstruct(snowflake) { - const BINARY = pad(Long.fromString(snowflake).toString(2), 64); + const BINARY = Util.idToBinary(snowflake).toString(2).padStart(64, '0'); const res = { timestamp: parseInt(BINARY.substring(0, 42), 2) + EPOCH, workerID: parseInt(BINARY.substring(42, 47), 2), @@ -68,8 +69,4 @@ class SnowflakeUtil { } } -function pad(v, n, c = '0') { - return String(v).length >= n ? String(v) : (String(c).repeat(n) + v).slice(-n); -} - module.exports = SnowflakeUtil; diff --git a/src/util/Structures.js b/src/util/Structures.js new file mode 100644 index 000000000..a1cb7e156 --- /dev/null +++ b/src/util/Structures.js @@ -0,0 +1,80 @@ +/** + * Allows for the extension of built-in Discord.js structures that are instantiated by {@link DataStore DataStores}. + */ +class Structures { + constructor() { + throw new Error(`The ${this.constructor.name} class may not be instantiated.`); + } + + /** + * Retrieves a structure class. + * @param {string} structure Name of the structure to retrieve + * @returns {Function} + */ + static get(structure) { + if (typeof structure === 'string') return structures[structure]; + throw new TypeError(`"structure" argument must be a string (received ${typeof structure})`); + } + + /** + * Extends a structure. + * @param {string} structure Name of the structure class to extend + * @param {Function} extender Function that takes the base class to extend as its only parameter and returns the + * extended class/prototype + * @returns {Function} Extended class/prototype returned from the extender + * @example + * const { Structures } = require('discord.js'); + * + * Structures.extend('Guild', Guild => { + * class CoolGuild extends Guild { + * constructor(client, data) { + * super(client, data); + * this.cool = true; + * } + * } + * + * return CoolGuild; + * }); + */ + static extend(structure, extender) { + if (!structures[structure]) throw new RangeError(`"${structure}" is not a valid extensible structure.`); + if (typeof extender !== 'function') { + const received = `(received ${typeof extender})`; + throw new TypeError( + `"extender" argument must be a function that returns the extended structure class/prototype ${received}` + ); + } + + const extended = extender(structures[structure]); + if (typeof extended !== 'function') { + throw new TypeError('The extender function must return the extended structure class/prototype.'); + } + if (Object.getPrototypeOf(extended) !== structures[structure]) { + throw new Error( + 'The class/prototype returned from the extender function must extend the existing structure class/prototype.' + ); + } + + structures[structure] = extended; + return extended; + } +} + +const structures = { + Emoji: require('../structures/Emoji'), + DMChannel: require('../structures/DMChannel'), + GroupDMChannel: require('../structures/GroupDMChannel'), + TextChannel: require('../structures/TextChannel'), + VoiceChannel: require('../structures/VoiceChannel'), + CategoryChannel: require('../structures/CategoryChannel'), + GuildChannel: require('../structures/GuildChannel'), + GuildMember: require('../structures/GuildMember'), + Guild: require('../structures/Guild'), + Message: require('../structures/Message'), + MessageReaction: require('../structures/MessageReaction'), + Presence: require('../structures/Presence').Presence, + Role: require('../structures/Role'), + User: require('../structures/User'), +}; + +module.exports = Structures; diff --git a/src/util/Util.js b/src/util/Util.js index ecc1b93e1..d92dc94b7 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -1,4 +1,3 @@ -const Long = require('long'); const snekfetch = require('snekfetch'); const { Colors, DefaultOptions, Endpoints } = require('./Constants'); const { Error: DiscordError, RangeError, TypeError } = require('../errors'); @@ -22,9 +21,7 @@ class Util { static splitMessage(text, { maxLength = 1950, char = '\n', prepend = '', append = '' } = {}) { if (text.length <= maxLength) return text; const splitText = text.split(char); - if (splitText.length === 1) { - throw new RangeError('SPLIT_MAX_LEN'); - } + if (splitText.length === 1) throw new RangeError('SPLIT_MAX_LEN'); const messages = ['']; let msg = 0; for (let i = 0; i < splitText.length; i++) { @@ -84,10 +81,7 @@ class Util { const [name, id] = text.split(':'); return { name, id }; } else { - return { - name: text, - id: null, - }; + return { name: text, id: null }; } } @@ -226,7 +220,6 @@ class Util { * @param {StringResolvable} data The string resolvable to resolve * @returns {string} */ - static resolveString(data) { if (typeof data === 'string') return data; if (data instanceof Array) return data.join('\n'); @@ -234,39 +227,34 @@ class Util { } /** - * Can be a Hex Literal, Hex String, Number, RGB Array, or one of the following + * Can be a number, hex string, an RGB array like: + * ```js + * [255, 0, 255] // purple * ``` - * [ - * 'DEFAULT', - * 'AQUA', - * 'GREEN', - * 'BLUE', - * 'PURPLE', - * 'GOLD', - * 'ORANGE', - * 'RED', - * 'GREY', - * 'DARKER_GREY', - * 'NAVY', - * 'DARK_AQUA', - * 'DARK_GREEN', - * 'DARK_BLUE', - * 'DARK_PURPLE', - * 'DARK_GOLD', - * 'DARK_ORANGE', - * 'DARK_RED', - * 'DARK_GREY', - * 'LIGHT_GREY', - * 'DARK_NAVY', - * 'RANDOM', - * ] - * ``` - * or something like - * ``` - * [255, 0, 255] - * ``` - * for purple - * @typedef {string|number|Array} ColorResolvable + * or one of the following strings: + * - `DEFAULT` + * - `AQUA` + * - `GREEN` + * - `BLUE` + * - `PURPLE` + * - `GOLD` + * - `ORANGE` + * - `RED` + * - `GREY` + * - `DARKER_GREY` + * - `NAVY` + * - `DARK_AQUA` + * - `DARK_GREEN` + * - `DARK_BLUE` + * - `DARK_PURPLE` + * - `DARK_GOLD` + * - `DARK_ORANGE` + * - `DARK_RED` + * - `DARK_GREY` + * - `LIGHT_GREY` + * - `DARK_NAVY` + * - `RANDOM` + * @typedef {string|number|number[]} ColorResolvable */ /** @@ -274,7 +262,6 @@ class Util { * @param {ColorResolvable} color Color to resolve * @returns {number} A color */ - static resolveColor(color) { if (typeof color === 'string') { if (color === 'RANDOM') return Math.floor(Math.random() * (0xFFFFFF + 1)); @@ -283,25 +270,36 @@ class Util { color = (color[0] << 16) + (color[1] << 8) + color[2]; } - if (color < 0 || color > 0xFFFFFF) { - throw new RangeError('COLOR_RANGE'); - } else if (color && isNaN(color)) { - throw new TypeError('COLOR_CONVERT'); - } + if (color < 0 || color > 0xFFFFFF) throw new RangeError('COLOR_RANGE'); + else if (color && isNaN(color)) throw new TypeError('COLOR_CONVERT'); return color; } /** - * Sorts by Discord's position and then by ID. + * Sorts by Discord's position and ID. * @param {Collection} collection Collection of objects to sort * @returns {Collection} */ static discordSort(collection) { - return collection - .sort((a, b) => a.rawPosition - b.rawPosition || Long.fromString(a.id).sub(Long.fromString(b.id)).toNumber()); + return collection.sort((a, b) => + a.rawPosition - b.rawPosition || + parseInt(a.id.slice(0, -10)) - parseInt(b.id.slice(0, -10)) || + parseInt(a.id.slice(10)) - parseInt(b.id.slice(10)) + ); } + /** + * Sets the position of a Channel or Role. + * @param {Channel|Role} item Object to set the position of + * @param {number} position New position for the object + * @param {boolean} relative Whether `position` is relative to its current position + * @param {Collection} sorted A collection of the objects sorted properly + * @param {APIRouter} route Route to call PATCH on + * @param {string} [reason] Reason for the change + * @returns {Promise} Updated item list, with `id` and `position` properties + * @private + */ static setPosition(item, position, relative, sorted, route, reason) { let updatedItems = sorted.array(); Util.moveElementInArray(updatedItems, item, position, relative); @@ -309,13 +307,77 @@ class Util { return route.patch({ data: updatedItems, reason }).then(() => updatedItems); } + /** + * Alternative to Node's `path.basename` that we have for some (probably stupid) reason. + * @param {string} path Path to get the basename of + * @param {string} [ext] File extension to remove + * @returns {string} Basename of the path + * @private + */ static basename(path, ext) { let f = splitPathRe.exec(path).slice(1)[2]; - if (ext && f.substr(-1 * ext.length) === ext) { - f = f.substr(0, f.length - ext.length); - } + if (ext && f.substr(-1 * ext.length) === ext) f = f.substr(0, f.length - ext.length); return f; } + + /** + * Transforms a snowflake from a decimal string to a bit string. + * @param {Snowflake} num Snowflake to be transformed + * @returns {string} + * @private + */ + static idToBinary(num) { + let bin = ''; + let high = parseInt(num.slice(0, -10)) || 0; + let low = parseInt(num.slice(-10)); + while (low > 0 || high > 0) { + bin = String(low & 1) + bin; + low = Math.floor(low / 2); + if (high > 0) { + low += 5000000000 * (high % 2); + high = Math.floor(high / 2); + } + } + return bin; + } + + /** + * Transforms a snowflake from a bit string to a decimal string. + * @param {string} num Bit string to be transformed + * @returns {Snowflake} + * @private + */ + static binaryToID(num) { + let dec = ''; + + while (num.length > 50) { + const high = parseInt(num.slice(0, -32), 2); + const low = parseInt((high % 10).toString(2) + num.slice(-32), 2); + + dec = (low % 10).toString() + dec; + num = Math.floor(high / 10).toString(2) + Math.floor(low / 10).toString(2).padStart(32, '0'); + } + + num = parseInt(num, 2); + while (num > 0) { + dec = (num % 10).toString() + dec; + num = Math.floor(num / 10); + } + + return dec; + } + + /** + * Creates a Promise that resolves after a specified duration. + * @param {number} ms How long to wait before resolving (in milliseconds) + * @returns {Promise} + * @private + */ + static delayFor(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); + } } module.exports = Util; diff --git a/travis/deploy.sh b/travis/deploy.sh index 340087312..ab6116008 100644 --- a/travis/deploy.sh +++ b/travis/deploy.sh @@ -24,12 +24,6 @@ else SOURCE_TYPE="branch" fi -# For Node != 8, do nothing -if [ "$TRAVIS_NODE_VERSION" != "8" ]; then - echo -e "\e[36m\e[1mBuild triggered with Node v${TRAVIS_NODE_VERSION} - doing nothing." - exit 0 -fi - # Run the build npm run docs VERSIONED=false npm run webpack @@ -88,4 +82,3 @@ git config user.name "Travis CI" git config user.email "$COMMIT_AUTHOR_EMAIL" git commit -m "Webpack build for ${SOURCE_TYPE} ${SOURCE}: ${SHA}" || true git push $SSH_REPO $TARGET_BRANCH - diff --git a/typings b/typings index 5131e88ff..0b5b13f4a 160000 --- a/typings +++ b/typings @@ -1 +1 @@ -Subproject commit 5131e88ffe0b61c2f69318e53e54a3e3edec6f1e +Subproject commit 0b5b13f4a521cba0fc42aa0f9b2c4a1abca2de3d