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/README.md b/README.md index 6b88f4370..989d2d652 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,7 @@ NPM downloads Build status Dependencies - Patreon + Patreon

NPM info @@ -31,9 +30,9 @@ discord.js is a powerful [node.js](https://nodejs.org) module that allows you to **Node.js 8.0.0 or newer is required.** Ignore any warnings about unmet peer dependencies, as they're all optional. -Without voice support: `npm install discord.js --save` -With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm install discord.js node-opus --save` -With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm install discord.js opusscript --save` +Without voice support: `npm i discord.js` +With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm i discord.js node-opus` +With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm i discord.js opusscript` ### Audio engines The preferred audio engine is node-opus, as it performs significantly better than opusscript. When both are available, discord.js will automatically choose node-opus. @@ -41,13 +40,13 @@ Using opusscript is only recommended for development environments where node-opu For production bots, using node-opus should be considered a necessity, especially if they're going to be running on multiple servers. ### Optional packages -- [zlib-sync](https://www.npmjs.com/package/zlib-sync) for significantly faster WebSocket data inflation (`npm install zlib-sync`) -- [bufferutil](https://www.npmjs.com/package/bufferutil) to greatly speed up the WebSocket when *not* using uws (`npm install bufferutil --save`) -- [erlpack](https://github.com/hammerandchisel/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install discordapp/erlpack --save`) +- [zlib-sync](https://www.npmjs.com/package/zlib-sync) for significantly faster WebSocket data inflation (`npm i zlib-sync`) +- [erlpack](https://github.com/discordapp/erlpack) for significantly faster WebSocket data (de)serialisation (`npm i discordapp/erlpack`) - One of the following packages can be installed for faster voice packet encryption and decryption: - - [sodium](https://www.npmjs.com/package/sodium) (`npm install sodium --save`) - - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm install libsodium-wrappers --save`) -- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws --save`) + - [sodium](https://www.npmjs.com/package/sodium) (`npm i sodium`) + - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm i libsodium-wrappers`) +- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm i uws`) +- [bufferutil](https://www.npmjs.com/package/bufferutil) for a much faster WebSocket connection when *not* using uws (`npm i bufferutil`) ## Example usage ```js @@ -70,11 +69,14 @@ client.login('your token'); ## Links * [Website](https://discord.js.org/) ([source](https://github.com/hydrabolt/discord.js-site)) * [Documentation](https://discord.js.org/#/docs) -* [Discord.js server](https://discord.gg/bRCvFy9) -* [Discord API server](https://discord.gg/rV4BwdK) +* [Discord.js Discord server](https://discord.gg/bRCvFy9) +* [Discord API Discord server](https://discord.gg/discord-api) * [GitHub](https://github.com/hydrabolt/discord.js) * [NPM](https://www.npmjs.com/package/discord.js) -* [Related libraries](https://discordapi.com/unofficial/libs.html) (see also [discord-rpc](https://www.npmjs.com/package/discord-rpc)) +* [Related libraries](https://discordapi.com/unofficial/libs.html) + +### Extensions +* [discord-rpc](https://www.npmjs.com/package/discord-rpc) ([github](https://github.com/devsnek/discord-rpc)) ## Contributing Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the 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 363f6b99c..fd2b846c8 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,11 @@ "runkitExampleFilename": "./docs/examples/ping.js", "unpkg": "./webpack/discord.min.js", "dependencies": { - "long": "^3.0.0", "pako": "^1.0.0", "prism-media": "^0.0.2", - "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", @@ -54,11 +53,11 @@ "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", "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/ClientManager.js b/src/client/ClientManager.js index 9e0d570d2..ad006b8fe 100644 --- a/src/client/ClientManager.js +++ b/src/client/ClientManager.js @@ -22,6 +22,7 @@ class ClientManager { /** * The status of the client + * @readonly * @type {number} */ get status() { 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/actions/ActionsManager.js b/src/client/actions/ActionsManager.js index 8341e7453..9708d17b2 100644 --- a/src/client/actions/ActionsManager.js +++ b/src/client/actions/ActionsManager.js @@ -14,13 +14,11 @@ class ActionsManager { this.register(require('./ChannelUpdate')); this.register(require('./GuildDelete')); this.register(require('./GuildUpdate')); - this.register(require('./GuildMemberGet')); this.register(require('./GuildMemberRemove')); this.register(require('./GuildBanRemove')); this.register(require('./GuildRoleCreate')); this.register(require('./GuildRoleDelete')); this.register(require('./GuildRoleUpdate')); - this.register(require('./UserGet')); this.register(require('./UserUpdate')); this.register(require('./UserNoteUpdate')); this.register(require('./GuildSync')); diff --git a/src/client/actions/GuildMemberGet.js b/src/client/actions/GuildMemberGet.js deleted file mode 100644 index 5bf2aafec..000000000 --- a/src/client/actions/GuildMemberGet.js +++ /dev/null @@ -1,10 +0,0 @@ -const Action = require('./Action'); - -class GuildMemberGetAction extends Action { - handle(guild, data) { - const member = guild.members.create(data); - return { member }; - } -} - -module.exports = GuildMemberGetAction; diff --git a/src/client/actions/UserGet.js b/src/client/actions/UserGet.js deleted file mode 100644 index 4a135dd58..000000000 --- a/src/client/actions/UserGet.js +++ /dev/null @@ -1,11 +0,0 @@ -const Action = require('./Action'); - -class UserGetAction extends Action { - handle(data) { - const client = this.client; - const user = client.users.create(data); - return { user }; - } -} - -module.exports = UserGetAction; diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index 678045dca..5a2c54c73 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -236,7 +236,7 @@ class VoiceBroadcast extends VolumeInterface { } /** - * Plays an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description) + * Plays an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description). * @param {string} input The arbitrary input * @param {StreamOptions} [options] Options for playing the stream * @returns {VoiceBroadcast} 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/WebSocketConnection.js b/src/client/websocket/WebSocketConnection.js index a30be35a8..4e3835ef0 100644 --- a/src/client/websocket/WebSocketConnection.js +++ b/src/client/websocket/WebSocketConnection.js @@ -272,7 +272,7 @@ class WebSocketConnection extends EventEmitter { try { const packet = WebSocket.unpack(this.inflate.result); this.onPacket(packet); - if (this.client.listenerCount('raw')) this.client.emit('raw', data); + if (this.client.listenerCount('raw')) this.client.emit('raw', packet); } catch (err) { this.client.emit('debug', err); } 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 746aab001..244c6a5ea 100644 --- a/src/index.js +++ b/src/index.js @@ -15,23 +15,44 @@ module.exports = { DataResolver: require('./util/DataResolver'), DataStore: require('./stores/DataStore'), DiscordAPIError: require('./rest/DiscordAPIError'), - EvaluatedPermissions: require('./util/Permissions'), 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, + // Stores + ChannelStore: require('./stores/ChannelStore'), + ClientPresenceStore: require('./stores/ClientPresenceStore'), + EmojiStore: require('./stores/EmojiStore'), + 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 escapeMarkdown: Util.escapeMarkdown, fetchRecommendedShards: Util.fetchRecommendedShards, splitMessage: Util.splitMessage, // Structures + Base: require('./structures/Base'), Activity: require('./structures/Presence').Activity, + CategoryChannel: require('./structures/CategoryChannel'), Channel: require('./structures/Channel'), - ClientUser: require('./structures/ClientUser'), + ClientApplication: require('./structures/ClientApplication'), + 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'), Collector: require('./structures/interfaces/Collector'), DMChannel: require('./structures/DMChannel'), @@ -48,15 +69,17 @@ module.exports = { MessageEmbed: require('./structures/MessageEmbed'), MessageMentions: require('./structures/MessageMentions'), MessageReaction: require('./structures/MessageReaction'), - ClientApplication: require('./structures/ClientApplication'), PermissionOverwrites: require('./structures/PermissionOverwrites'), Presence: require('./structures/Presence').Presence, - ReactionEmoji: require('./structures/ReactionEmoji'), ReactionCollector: require('./structures/ReactionCollector'), + ReactionEmoji: require('./structures/ReactionEmoji'), + RichPresenceAssets: require('./structures/Presence').RichPresenceAssets, Role: require('./structures/Role'), TextChannel: require('./structures/TextChannel'), User: require('./structures/User'), + UserConnection: require('./structures/UserConnection'), VoiceChannel: require('./structures/VoiceChannel'), + VoiceRegion: require('./structures/VoiceRegion'), Webhook: require('./structures/Webhook'), WebSocket: require('./WebSocket'), diff --git a/src/rest/RESTManager.js b/src/rest/RESTManager.js index 48476cca6..d62423979 100644 --- a/src/rest/RESTManager.js +++ b/src/rest/RESTManager.js @@ -12,12 +12,22 @@ class RESTManager { this.globallyRateLimited = false; this.tokenPrefix = tokenPrefix; this.versioned = true; + this.timeDifferences = []; } get api() { return routeBuilder(this); } + get timeDifference() { + return Math.round(this.timeDifferences.reduce((a, b) => a + b, 0) / this.timeDifferences.length); + } + + set timeDifference(ms) { + this.timeDifferences.unshift(ms); + if (this.timeDifferences.length > 5) this.timeDifferences.length = 5; + } + getAuth() { const token = this.client.token || this.client.accessToken; const prefixed = !!this.client.application || (this.client.user && this.client.user.bot); diff --git a/src/rest/handlers/RequestHandler.js b/src/rest/handlers/RequestHandler.js index aaeed9d69..c4226a45c 100644 --- a/src/rest/handlers/RequestHandler.js +++ b/src/rest/handlers/RequestHandler.js @@ -9,7 +9,6 @@ class RequestHandler { this.limit = Infinity; this.resetTime = null; this.remaining = 1; - this.timeDifference = 0; this.queue = []; } @@ -32,7 +31,7 @@ class RequestHandler { const finish = timeout => { if (timeout || this.limited) { if (!timeout) { - timeout = this.resetTime - Date.now() + this.timeDifference + this.client.options.restTimeOffset; + timeout = this.resetTime - Date.now() + this.manager.timeDifference + this.client.options.restTimeOffset; } // eslint-disable-next-line prefer-promise-reject-errors reject({ timeout }); @@ -40,17 +39,18 @@ 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, limit: this.limit, - timeDifference: this.timeDifference, + timeDifference: this.manager.timeDifference, method: item.request.method, path: item.request.path, route: item.request.route, @@ -66,7 +66,7 @@ class RequestHandler { this.limit = Number(res.headers['x-ratelimit-limit']); this.resetTime = Number(res.headers['x-ratelimit-reset']) * 1000; this.remaining = Number(res.headers['x-ratelimit-remaining']); - this.timeDifference = Date.now() - new Date(res.headers.date).getTime(); + this.manager.timeDifference = Date.now() - new Date(res.headers.date).getTime(); } if (err) { if (err.status === 429) { 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/ChannelStore.js b/src/stores/ChannelStore.js index 5491b8e59..2ceee8000 100644 --- a/src/stores/ChannelStore.js +++ b/src/stores/ChannelStore.js @@ -95,7 +95,7 @@ class ChannelStore extends DataStore { * @memberof ChannelStore * @instance * @param {ChannelResolvable} channel The channel resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ } diff --git a/src/stores/DataStore.js b/src/stores/DataStore.js index 85ce17c90..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); } @@ -37,7 +39,7 @@ class DataStore extends Collection { /** * Resolves a data entry to a instance ID. * @param {string|Instance} idOrInstance The id or instance of something in this DataStore - * @returns {?string} + * @returns {?Snowflake} */ resolveID(idOrInstance) { if (idOrInstance instanceof this.holds) return idOrInstance.id; diff --git a/src/stores/EmojiStore.js b/src/stores/EmojiStore.js index 83b4df812..035cc3d4d 100644 --- a/src/stores/EmojiStore.js +++ b/src/stores/EmojiStore.js @@ -38,7 +38,7 @@ class EmojiStore extends DataStore { /** * Resolves a EmojiResolvable to a Emoji ID string. * @param {EmojiResolvable} emoji The Emoji resolvable to identify - * @returns {?string} + * @returns {?Snowflake} */ resolveID(emoji) { if (emoji instanceof ReactionEmoji) return emoji.id; diff --git a/src/stores/GuildChannelStore.js b/src/stores/GuildChannelStore.js index 3e03c8110..0bc3e8c4e 100644 --- a/src/stores/GuildChannelStore.js +++ b/src/stores/GuildChannelStore.js @@ -42,7 +42,7 @@ class GuildChannelStore extends DataStore { * @memberof GuildChannelStore * @instance * @param {GuildChannelResolvable} channel The GuildChannel resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ } diff --git a/src/stores/GuildMemberStore.js b/src/stores/GuildMemberStore.js index ba8cac903..ad0acbed9 100644 --- a/src/stores/GuildMemberStore.js +++ b/src/stores/GuildMemberStore.js @@ -41,7 +41,7 @@ class GuildMemberStore extends DataStore { /** * Resolves a GuildMemberResolvable to an member ID string. * @param {GuildMemberResolvable} member The user that is part of the guild - * @returns {?string} + * @returns {?Snowflake} */ resolveID(member) { const memberResolveable = super.resolveID(member); diff --git a/src/stores/GuildStore.js b/src/stores/GuildStore.js index 5e3e792a4..45e9c9cfc 100644 --- a/src/stores/GuildStore.js +++ b/src/stores/GuildStore.js @@ -33,7 +33,7 @@ class GuildStore extends DataStore { * @memberof GuildStore * @instance * @param {GuildResolvable} guild The guild resolvable to identify - * @returns {?string} + * @returns {?Snowflake} */ } diff --git a/src/stores/MessageStore.js b/src/stores/MessageStore.js index 75d01620c..cc0ff60b0 100644 --- a/src/stores/MessageStore.js +++ b/src/stores/MessageStore.js @@ -112,7 +112,7 @@ class MessageStore extends DataStore { * @memberof MessageStore * @instance * @param {MessageResolvable} message The message resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ } diff --git a/src/stores/PresenceStore.js b/src/stores/PresenceStore.js index 1c2649712..8322c9c65 100644 --- a/src/stores/PresenceStore.js +++ b/src/stores/PresenceStore.js @@ -39,7 +39,7 @@ class PresenceStore extends DataStore { /** * Resolves a PresenceResolvable to a Presence ID string. * @param {PresenceResolvable} presence The presence resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ resolveID(presence) { const presenceResolveable = super.resolveID(presence); diff --git a/src/stores/ReactionStore.js b/src/stores/ReactionStore.js index bcbca72ac..c11b4e176 100644 --- a/src/stores/ReactionStore.js +++ b/src/stores/ReactionStore.js @@ -38,7 +38,7 @@ class ReactionStore extends DataStore { * @memberof ReactionStore * @instance * @param {MessageReactionResolvable} role The role resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ } 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/stores/RoleStore.js b/src/stores/RoleStore.js index 7501cb459..bb8cd749d 100644 --- a/src/stores/RoleStore.js +++ b/src/stores/RoleStore.js @@ -38,7 +38,7 @@ class RoleStore extends DataStore { * @memberof RoleStore * @instance * @param {RoleResolvable} role The role resolvable to resolve - * @returns {?string} + * @returns {?Snowflake} */ } diff --git a/src/stores/UserStore.js b/src/stores/UserStore.js index 6ee4909a3..432a0768a 100644 --- a/src/stores/UserStore.js +++ b/src/stores/UserStore.js @@ -35,7 +35,7 @@ class UserStore extends DataStore { /** * Resolves a UserResolvable to a user ID string. * @param {UserResolvable} user The UserResolvable to identify - * @returns {?string} + * @returns {?Snowflake} */ resolveID(user) { if (user instanceof GuildMember) return user.user.id; 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 0d2646cee..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()], @@ -177,7 +178,7 @@ class ClientApplication extends Base { /** * Resets the app's secret. * This is only available when using a user account. - * @returns {ClientApplication} + * @returns {Promise} */ resetSecret() { return this.client.api.oauth2.applications[this.id].reset.post() @@ -187,7 +188,7 @@ class ClientApplication extends Base { /** * Resets the app's bot token. * This is only available when using a user account. - * @returns {ClientApplication} + * @returns {Promise} */ resetToken() { return this.client.api.oauth2.applications[this.id].bot.reset.post() @@ -195,8 +196,12 @@ class ClientApplication extends Base { } /** - * When concatenated with a string, this automatically concatenates the app name rather than the app object. + * When concatenated with a string, this automatically returns the application's name instead of the + * ClientApplication object. * @returns {string} + * @example + * // Logs: Application name: My App + * console.log(`Application name: ${application}`); */ toString() { return this.name; diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js index cc19ad58b..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); @@ -88,6 +88,8 @@ class ClientUser extends User { this.guildSettings.set(settings.guild_id, new ClientUserGuildSettings(this.client, settings)); } } + + if (data.token) this.client.token = data.token; } /** @@ -246,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/DMChannel.js b/src/structures/DMChannel.js index cdd4d2db8..fe6ba57c6 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -27,9 +27,12 @@ class DMChannel extends Channel { } /** - * When concatenated with a string, this automatically concatenates the recipient's mention instead of the - * DM channel object. + * When concatenated with a string, this automatically returns the recipient's mention instead of the + * DMChannel object. * @returns {string} + * @example + * // Logs: Hello from <@123456789012345678>! + * console.log(`Hello from ${channel}!`); */ toString() { return this.recipient.toString(); diff --git a/src/structures/Emoji.js b/src/structures/Emoji.js index 4b1ea2680..d1cca2843 100644 --- a/src/structures/Emoji.js +++ b/src/structures/Emoji.js @@ -190,7 +190,7 @@ class Emoji extends Base { } /** - * When concatenated with a string, this automatically returns the emoji mention rather than the object. + * When concatenated with a string, this automatically concatenates the emoji's mention instead of the Emoji object. * @returns {string} * @example * // Send an emoji: diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js index e142b070d..2ade06b06 100644 --- a/src/structures/GroupDMChannel.js +++ b/src/structures/GroupDMChannel.js @@ -203,14 +203,12 @@ class GroupDMChannel extends Channel { } /** - * When concatenated with a string, this automatically concatenates the channel's name instead of the Channel object. + * When concatenated with a string, this automatically returns the channel's name instead of the + * GroupDMChannel object. * @returns {string} * @example * // Logs: Hello from My Group DM! * console.log(`Hello from ${channel}!`); - * @example - * // Logs: Hello from My Group DM! - * console.log(`Hello from ' + channel + '!'); */ toString() { return this.name; diff --git a/src/structures/Guild.js b/src/structures/Guild.js index d96329be0..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 {Object[]} + * 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() { @@ -401,7 +411,7 @@ class Guild extends Base { } } - /* + /** * The `@everyone` role of the guild * @type {Role} * @readonly @@ -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,8 +559,8 @@ class Guild extends Base { roles.push(role.id); } } - return this.client.api.guilds(this.id).members(user.id).put({ data: options }) - .then(data => this.client.actions.GuildMemberGet.handle(this, data).member); + 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|number|string} [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 @@ -979,7 +997,7 @@ class Guild extends Base { } /** - * Creates a new role in the guild with given information + * Creates a new role in the guild with given information. * The position will silently reset to 1 if an invalid one is provided, or none. * @param {Object} [options] Options * @param {RoleData} [options.data] The data to update the role with @@ -1000,7 +1018,7 @@ class Guild extends Base { * reason: 'we needed a role for Super Cool People', * }) * .then(role => console.log(`Created role ${role}`)) - * .catch(console.error) + * .catch(console.error); */ createRole({ data = {}, reason } = {}) { if (data.color) data.color = Util.resolveColor(data.color); @@ -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 })); } /** @@ -1123,19 +1140,44 @@ class Guild extends Base { } /** - * When concatenated with a string, this automatically concatenates the guild's name instead of the guild object. + * When concatenated with a string, this automatically returns the guild's name instead of the Guild object. * @returns {string} * @example * // Logs: Hello from My Guild! * console.log(`Hello from ${guild}!`); - * @example - * // Logs: Hello from My Guild! - * console.log('Hello from ' + guild + '!'); */ toString() { 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) { @@ -1149,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/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js index c48da00c0..d43a48b29 100644 --- a/src/structures/GuildAuditLogs.js +++ b/src/structures/GuildAuditLogs.js @@ -2,6 +2,24 @@ const Collection = require('../util/Collection'); const Snowflake = require('../util/Snowflake'); const Webhook = require('./Webhook'); +/** + * The target type of an entry, e.g. `GUILD`. Here are the available types: + * * GUILD + * * CHANNEL + * * USER + * * ROLE + * * INVITE + * * WEBHOOK + * * EMOJI + * * MESSAGE + * @typedef {string} AuditLogTargetType + */ + +/** + * Key mirror of all available audit log targets. + * @name GuildAuditLogs.Targets + * @type {AuditLogTargetType} + */ const Targets = { ALL: 'ALL', GUILD: 'GUILD', @@ -15,6 +33,43 @@ const Targets = { UNKNOWN: 'UNKNOWN', }; +/** + * The action of an entry. Here are the available actions: + * * ALL: null + * * GUILD_UPDATE: 1 + * * CHANNEL_CREATE: 10 + * * CHANNEL_UPDATE: 11 + * * CHANNEL_DELETE: 12 + * * CHANNEL_OVERWRITE_CREATE: 13 + * * CHANNEL_OVERWRITE_UPDATE: 14 + * * CHANNEL_OVERWRITE_DELETE: 15 + * * MEMBER_KICK: 20 + * * MEMBER_PRUNE: 21 + * * MEMBER_BAN_ADD: 22 + * * MEMBER_BAN_REMOVE: 23 + * * MEMBER_UPDATE: 24 + * * MEMBER_ROLE_UPDATE: 25 + * * ROLE_CREATE: 30 + * * ROLE_UPDATE: 31 + * * ROLE_DELETE: 32 + * * INVITE_CREATE: 40 + * * INVITE_UPDATE: 41 + * * INVITE_DELETE: 42 + * * WEBHOOK_CREATE: 50 + * * WEBHOOK_UPDATE: 51 + * * WEBHOOK_DELETE: 50 + * * EMOJI_CREATE: 60 + * * EMOJI_UPDATE: 61 + * * EMOJI_DELETE: 62 + * * MESSAGE_DELETE: 72 + * @typedef {?number|string} AuditLogAction + */ + +/** + * All available actions keyed under their names to their numeric values. + * @name GuildAuditLogs.Actions + * @type {AuditLogAction} + */ const Actions = { ALL: null, GUILD_UPDATE: 1, @@ -85,20 +140,7 @@ class GuildAuditLogs { } /** - * The target type of an entry, e.g. `GUILD`. Here are the available types: - * * GUILD - * * CHANNEL - * * USER - * * ROLE - * * INVITE - * * WEBHOOK - * * EMOJI - * * MESSAGE - * @typedef {string} TargetType - */ - - /** - * The target for an audit log entry. It can be one of: + * The target of an entry. It can be one of: * * A guild * * A user * * A role @@ -106,13 +148,13 @@ class GuildAuditLogs { * * An invite * * A webhook * * An object where the keys represent either the new value or the old value - * @typedef {?Object|Guild|User|Role|Emoji|Invite|Webhook} EntryTarget + * @typedef {?Object|Guild|User|Role|Emoji|Invite|Webhook} AuditLogEntryTarget */ /** * Finds the target type from the entry action. - * @param {number} target The action target - * @returns {?string} + * @param {AuditLogAction} target The action target + * @returns {AuditLogTargetType} */ static targetType(target) { if (target < 10) return Targets.GUILD; @@ -131,13 +173,14 @@ class GuildAuditLogs { * * CREATE * * DELETE * * UPDATE - * @typedef {string} ActionType + * * ALL + * @typedef {string} AuditLogActionType */ /** * Finds the action type from the entry action. - * @param {string} action The action target - * @returns {string} + * @param {AuditLogAction} action The action target + * @returns {AuditLogActionType} */ static actionType(action) { if ([ @@ -187,19 +230,19 @@ class GuildAuditLogsEntry { const targetType = GuildAuditLogs.targetType(data.action_type); /** * The target type of this entry - * @type {TargetType} + * @type {AuditLogTargetType} */ this.targetType = targetType; /** * The action type of this entry - * @type {ActionType} + * @type {AuditLogActionType} */ this.actionType = GuildAuditLogs.actionType(data.action_type); /** - * Specific action type of this entry - * @type {string} + * Specific action type of this entry in its string presentation + * @type {AuditLogAction} */ this.action = Object.keys(Actions).find(k => Actions[k] === data.action_type); @@ -271,7 +314,7 @@ class GuildAuditLogsEntry { if (targetType === Targets.UNKNOWN) { /** * The target of this entry - * @type {EntryTarget} + * @type {AuditLogEntryTarget} */ this.target = this.changes.reduce((o, c) => { o[c.key] = c.new || c.old; diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js index 66efd8c2b..a84ad0847 100644 --- a/src/structures/GuildChannel.js +++ b/src/structures/GuildChannel.js @@ -248,6 +248,7 @@ class GuildChannel extends Channel { * @property {string} [name] The name of the channel * @property {number} [position] The position of the channel * @property {string} [topic] The topic of the text channel + * @property {boolean} [nsfw] Whether the channel is NSFW * @property {number} [bitrate] The bitrate of the voice channel * @property {number} [userLimit] The user limit of the voice channel * @property {Snowflake} [parentID] The parent ID of the channel @@ -290,8 +291,9 @@ class GuildChannel extends Channel { data: { name: (data.name || this.name).trim(), 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, @@ -491,11 +493,8 @@ class GuildChannel extends Channel { * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object. * @returns {string} * @example - * // Outputs: Hello from #general - * console.log(`Hello from ${channel}`); - * @example - * // Outputs: Hello from #general - * console.log('Hello from ' + channel); + * // Logs: Hello from <#123456789012345678>! + * console.log(`Hello from ${channel}!`); */ toString() { return `<#${this.id}>`; diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index c4c2d6a81..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; } @@ -125,7 +131,7 @@ class GuildMember extends Base { * @readonly */ get presence() { - return this.frozenPresence || this.guild.presences.get(this.id) || new Presence(); + return this.frozenPresence || this.guild.presences.get(this.id) || new Presence(this.client); } /** @@ -294,19 +300,13 @@ class GuildMember extends Base { /** * Checks if any of the member's roles have a permission. * @param {PermissionResolvable|PermissionResolvable[]} permission Permission(s) to check for - * @param {boolean} [explicit=false] Whether to require the role to explicitly have the exact permission - * **(deprecated)** - * @param {boolean} [checkAdmin] Whether to allow the administrator permission to override - * (takes priority over `explicit`) - * @param {boolean} [checkOwner] Whether to allow being the guild's owner to override - * (takes priority over `explicit`) + * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override + * @param {boolean} [checkOwner=true] Whether to allow being the guild's owner to override * @returns {boolean} */ - hasPermission(permission, explicit = false, checkAdmin, checkOwner) { - if (typeof checkAdmin === 'undefined') checkAdmin = !explicit; - if (typeof checkOwner === 'undefined') checkOwner = !explicit; + hasPermission(permission, checkAdmin = true, checkOwner = true) { if (checkOwner && this.user.id === this.guild.ownerID) return true; - return this.roles.some(r => r.permissions.has(permission, undefined, checkAdmin)); + return this.roles.some(r => r.permissions.has(permission, checkAdmin)); } /** @@ -521,8 +521,7 @@ class GuildMember extends Base { /** * Bans this guild member. - * @param {Object|number|string} [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} @@ -535,10 +534,10 @@ class GuildMember extends Base { } /** - * When concatenated with a string, this automatically concatenates the user's mention instead of the Member object. + * When concatenated with a string, this automatically returns the user's mention instead of the GuildMember object. * @returns {string} * @example - * // Logs: Hello from <@123456789>! + * // Logs: Hello from <@123456789012345678>! * console.log(`Hello from ${member}!`); */ toString() { 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/MessageEmbed.js b/src/structures/MessageEmbed.js index da55d6cee..b7f430b75 100644 --- a/src/structures/MessageEmbed.js +++ b/src/structures/MessageEmbed.js @@ -132,12 +132,12 @@ class MessageEmbed { proxyIconURL: data.footer.proxyIconURL || data.footer.proxy_icon_url, } : null; - /** - * The files of this embed - * @type {?Object} - * @property {Array} files Files to attach - */ if (data.files) { + /** + * The files of this embed + * @type {?Object} + * @property {Array} files Files to attach + */ this.files = data.files.map(file => { if (file instanceof MessageAttachment) { return typeof file.file === 'string' ? file.file : Util.cloneObject(file.file); @@ -158,7 +158,7 @@ class MessageEmbed { /** * The hexadecimal version of the embed color, with a leading hash - * @type {string} + * @type {?string} * @readonly */ get hexColor() { 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/Presence.js b/src/structures/Presence.js index 1eba8170c..535c7d11d 100644 --- a/src/structures/Presence.js +++ b/src/structures/Presence.js @@ -19,7 +19,7 @@ class Presence { * * **`dnd`** - user is in Do Not Disturb * @type {string} */ - this.status = data.status || this.status; + this.status = data.status || this.status || 'offline'; const activity = data.game || data.activity; /** @@ -38,7 +38,7 @@ class Presence { } /** - * Whether this presence is equal to another + * Whether this presence is equal to another. * @param {Presence} presence The presence to compare with * @returns {boolean} */ @@ -160,21 +160,22 @@ class RichPresenceAssets { /** * ID of the large image asset - * @type {?string} + * @type {?Snowflake} */ this.largeImage = assets.large_image || null; /** * ID of the small image asset - * @type {?string} + * @type {?Snowflake} */ this.smallImage = assets.small_image || null; } /** * Gets the URL of the small image asset - * @param {string} format Format of the image - * @param {number} size Size of the image + * @param {Object} [options] Options for the image url + * @param {string} [options.format] Format of the image + * @param {number} [options.size] Size of the image * @returns {?string} The small image URL */ smallImageURL({ format, size } = {}) { @@ -185,8 +186,9 @@ class RichPresenceAssets { /** * Gets the URL of the large image asset - * @param {string} format Format of the image - * @param {number} size Size of the image + * @param {Object} [options] Options for the image url + * @param {string} [options.format] Format of the image + * @param {number} [options.size] Size of the image * @returns {?string} The large image URL */ largeImageURL({ format, size } = {}) { 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/ReactionEmoji.js b/src/structures/ReactionEmoji.js index f550544c6..94ea38930 100644 --- a/src/structures/ReactionEmoji.js +++ b/src/structures/ReactionEmoji.js @@ -35,11 +35,12 @@ class ReactionEmoji { } /** - * Creates the text required to form a graphical emoji on Discord. + * When concatenated with a string, this automatically returns the text required to form a graphical emoji on Discord + * instead of the ReactionEmoji object. + * @returns {string} * @example * // Send the emoji used in a reaction to the channel the reaction is part of - * reaction.message.channel.send(`The emoji used is ${reaction.emoji}`); - * @returns {string} + * reaction.message.channel.send(`The emoji used was: ${reaction.emoji}`); */ toString() { return this.id ? `<:${this.name}:${this.id}>` : this.name; diff --git a/src/structures/Role.js b/src/structures/Role.js index eb60185db..39c28e11b 100644 --- a/src/structures/Role.js +++ b/src/structures/Role.js @@ -332,8 +332,11 @@ class Role extends Base { } /** - * When concatenated with a string, this automatically concatenates the role mention rather than the Role object. + * When concatenated with a string, this automatically returns the role's mention instead of the Role object. * @returns {string} + * @example + * // Logs: Role: <@&123456789012345678> + * console.log(`Role: ${role}`); */ toString() { if (this.id === this.guild.id) return '@everyone'; diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js index 6529f46b9..5679f841a 100644 --- a/src/structures/TextChannel.js +++ b/src/structures/TextChannel.js @@ -38,6 +38,16 @@ class TextChannel extends GuildChannel { if (data.messages) for (const message of data.messages) this.messages.create(message); } + /** + * Sets whether this channel is flagged as NSFW. + * @param {boolean} nsfw Whether the channel should be considered NSFW + * @param {string} [reason] Reason for changing the channel's NSFW flag + * @returns {Promise} + */ + setNSFW(nsfw, reason) { + return this.edit({ nsfw }, reason); + } + /** * Fetches all webhooks for the channel. * @returns {Promise>} diff --git a/src/structures/User.js b/src/structures/User.js index 814d3a90c..001a999be 100644 --- a/src/structures/User.js +++ b/src/structures/User.js @@ -20,6 +20,13 @@ class User extends Base { */ this.id = data.id; + /** + * Whether or not the user is a bot + * @type {boolean} + * @name User#bot + */ + this.bot = Boolean(data.bot); + this._patch(data); } @@ -40,18 +47,11 @@ class User extends Base { /** * The ID of the user's avatar - * @type {string} + * @type {?string} * @name User#avatar */ if (typeof data.avatar !== 'undefined') this.avatar = data.avatar; - /** - * Whether or not the user is a bot - * @type {boolean} - * @name User#bot - */ - if (typeof this.bot === 'undefined' && typeof data.bot !== 'undefined') this.bot = Boolean(data.bot); - /** * The ID of the last message sent by the user, if one was sent * @type {?Snowflake} @@ -63,8 +63,6 @@ class User extends Base { * @type {?Message} */ this.lastMessage = null; - - if (data.token) this.client.token = data.token; } /** @@ -95,7 +93,7 @@ class User extends Base { for (const guild of this.client.guilds.values()) { if (guild.presences.has(this.id)) return guild.presences.get(this.id); } - return new Presence(); + return new Presence(this.client); } /** @@ -250,10 +248,10 @@ class User extends Base { } /** - * When concatenated with a string, this automatically concatenates the user's mention instead of the User object. + * When concatenated with a string, this automatically returns the user's mention instead of the User object. * @returns {string} * @example - * // logs: Hello from <@123456789>! + * // Logs: Hello from <@123456789012345678>! * console.log(`Hello from ${user}!`); */ toString() { diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index a781a25a8..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. @@ -79,7 +75,6 @@ class Webhook { * (see [here](https://discordapp.com/developers/docs/resources/channel#embed-object) for more details) * @property {boolean} [disableEveryone=this.client.options.disableEveryone] Whether or not @everyone and @here * should be replaced with plain-text - * @property {FileOptions|BufferResolvable} [file] A file to send with the message * @property {FileOptions[]|string[]} [files] Files to send with the message * @property {string|boolean} [code] Language for optional codeblock formatting to apply * @property {boolean|SplitOptions} [split=false] Whether or not the message should be split into multiple messages if @@ -99,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 971f2483d..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'); /** @@ -11,7 +11,7 @@ const { TypeError } = require('../../errors'); * @property {ChannelResolvable} [channel] Channel to limit search to (only for guild search endpoint) * @property {UserResolvable} [author] Author to limit search * @property {string} [authorType] One of `user`, `bot`, `webhook`, or add `-` to negate (e.g. `-webhook`) - * @property {string} [sortBy='recent'] `recent` or `relevant` + * @property {string} [sortBy='timestamp'] `timestamp` or `relevant` * @property {string} [sortOrder='descending'] `ascending` or `descending` * @property {number} [contextSize=2] How many messages to get around the matched message (0 to 2) * @property {number} [limit=25] Maximum number of results to get (1 to 25) @@ -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 aed65b8ee..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: { @@ -114,6 +114,7 @@ exports.Endpoints = { Asset: name => `${root}/assets/${name}`, DefaultAvatar: number => `${root}/embed/avatars/${number}.png`, Avatar: (userID, hash, format = 'default', size) => { + if (userID === '1') return hash; if (format === 'default') format = hash.startsWith('a_') ? 'gif' : 'webp'; return makeImageUrl(`${root}/avatars/${userID}/${hash}`, { format, size }); }, 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 6bbfb91d1..d7fbe8b87 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 NODE_ENV=production npm run build:browser @@ -87,4 +81,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 697fc933d..0b5b13f4a 160000 --- a/typings +++ b/typings @@ -1 +1 @@ -Subproject commit 697fc933de90209b81b69bd0fe87883e3c7a217d +Subproject commit 0b5b13f4a521cba0fc42aa0f9b2c4a1abca2de3d