diff --git a/.gitignore b/.gitignore index c6e423898..ffe623718 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,21 @@ -# Packages -node_modules/ -yarn.lock - -# Log files -logs/ -*.log - -# Authentication -test/auth.json -test/auth.js -docs/deploy/deploy_key -docs/deploy/deploy_key.pub -deploy/deploy_key -deploy/deploy_key.pub - -# Miscellaneous -.tmp/ -.vscode/ -docs/docs.json -webpack/ +# Packages +node_modules/ +yarn.lock + +# Log files +logs/ +*.log + +# Authentication +test/auth.json +test/auth.js +docs/deploy/deploy_key +docs/deploy/deploy_key.pub +deploy/deploy_key +deploy/deploy_key.pub + +# Miscellaneous +.tmp/ +.vscode/ +docs/docs.json +webpack/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..579e0e4d9 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-json=false diff --git a/.tern-project b/.tern-project index cc31d86e5..8f37bf06e 100644 --- a/.tern-project +++ b/.tern-project @@ -1,11 +1,21 @@ { - "ecmaVersion": 6, + "ecmaVersion": 7, "libs": [], + "loadEagerly": [ + "./src/*.js" + ], + "dontLoad": [ + "node_modules/**" + ], "plugins": { - "node": { - "dontLoad": "node_modules/**", - "load": "", - "modules": "" + "es_modules": {}, + "node": {}, + "doc_comment": { + "fullDocs": true, + "strong": true + }, + "webpack": { + "configPath": "./webpack.config.js", } } } diff --git a/.travis.yml b/.travis.yml index e1f264647..74323ddbf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,16 +1,20 @@ -language: node_js -node_js: - - "6" - - "7" -cache: - directories: - - node_modules -install: npm install -script: - - bash ./deploy/deploy.sh -env: - global: - - ENCRYPTION_LABEL: "af862fa96d3e" - - COMMIT_AUTHOR_EMAIL: "amishshah.2k@gmail.com" -dist: trusty -sudo: false +language: node_js +node_js: + - "6" + - "7" +cache: + directories: + - node_modules +install: npm install +script: bash ./deploy/test.sh +jobs: + include: + - stage: build + node_js: "6" + script: bash ./deploy/deploy.sh +env: + global: + - ENCRYPTION_LABEL: "af862fa96d3e" + - COMMIT_AUTHOR_EMAIL: "amishshah.2k@gmail.com" +dist: trusty +sudo: false diff --git a/browser.js b/browser.js new file mode 100644 index 000000000..9f9341efc --- /dev/null +++ b/browser.js @@ -0,0 +1,9 @@ +const browser = typeof window !== 'undefined'; +const webpack = !!process.env.__DISCORD_WEBPACK__; + +const Discord = require('./'); + +module.exports = Discord; +if (browser && webpack) window.Discord = Discord; // eslint-disable-line no-undef +// eslint-disable-next-line no-console +else if (!browser) console.warn('Warning: Attempting to use browser version of Discord.js in a non-browser environment!'); diff --git a/deploy/deploy.sh b/deploy/deploy.sh index c132a20f6..d12ee608b 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -3,15 +3,7 @@ set -e -function tests { - npm run lint - npm run docs:test - VERSIONED=false npm run webpack - exit 0 -} - function build { - npm run lint npm run docs VERSIONED=false npm run webpack } @@ -22,10 +14,10 @@ if [[ "$TRAVIS_BRANCH" == revert-* ]]; then exit 0 fi -# For PRs, only run tests +# For PRs, do nothing if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then - echo -e "\e[36m\e[1mBuild triggered for PR #${TRAVIS_PULL_REQUEST} to branch \"${TRAVIS_BRANCH}\" - only running tests." - tests + echo -e "\e[36m\e[1mBuild triggered for PR #${TRAVIS_PULL_REQUEST} to branch \"${TRAVIS_BRANCH}\" - doing nothing." + exit 0 fi # Figure out the source of the build @@ -39,10 +31,10 @@ else SOURCE_TYPE="branch" fi -# For Node != 6, only run tests +# For Node != 6, do nothing if [ "$TRAVIS_NODE_VERSION" != "6" ]; then - echo -e "\e[36m\e[1mBuild triggered with Node v${TRAVIS_NODE_VERSION} - only running tests." - tests + echo -e "\e[36m\e[1mBuild triggered with Node v${TRAVIS_NODE_VERSION} - doing nothing." + exit 0 fi build diff --git a/deploy/test.sh b/deploy/test.sh new file mode 100644 index 000000000..9e076a4c4 --- /dev/null +++ b/deploy/test.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -e + +function tests { + npm run lint + npm run docs:test + exit 0 +} + +# For revert branches, do nothing +if [[ "$TRAVIS_BRANCH" == revert-* ]]; then + echo -e "\e[36m\e[1mTest triggered for reversion branch \"${TRAVIS_BRANCH}\" - doing nothing." + exit 0 +fi + +# For PRs +if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then + echo -e "\e[36m\e[1mTest triggered for PR #${TRAVIS_PULL_REQUEST} to branch \"${TRAVIS_BRANCH}\" - only running tests." + tests +fi + +# Figure out the source of the test +if [ -n "$TRAVIS_TAG" ]; then + echo -e "\e[36m\e[1mTest triggered for tag \"${TRAVIS_TAG}\"." +else + echo -e "\e[36m\e[1mTest triggered for branch \"${TRAVIS_BRANCH}\"." +fi + +# For Node != 6 +if [ "$TRAVIS_NODE_VERSION" != "6" ]; then + echo -e "\e[36m\e[1mTest triggered with Node v${TRAVIS_NODE_VERSION} - only running tests." + tests +fi diff --git a/docs/README.md b/docs/README.md index d41af8eaf..b5ac7978f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1 +1 @@ -## [View the documentation here.](https://discord.js.org/#/docs) +## [View the documentation here.](https://discord.js.org/#/docs) diff --git a/docs/examples/greeting.js b/docs/examples/greeting.js index e9c92e076..55bf2d00d 100644 --- a/docs/examples/greeting.js +++ b/docs/examples/greeting.js @@ -19,11 +19,7 @@ client.on('ready', () => { // Create an event listener for new guild members client.on('guildMemberAdd', member => { - // Send the message to the guilds default channel (usually #general), mentioning the member - member.guild.defaultChannel.send(`Welcome to the server, ${member}!`); - - // If you want to send the message to a designated channel on a server instead - // you can do the following: + // Send the message to a designated channel on a server: const channel = member.guild.channels.find('name', 'member-log'); // Do nothing if the channel wasn't found on this server if (!channel) return; diff --git a/docs/general/updating.md b/docs/general/updating.md index cc2c7399f..8c54e8fa7 100644 --- a/docs/general/updating.md +++ b/docs/general/updating.md @@ -1,3 +1,7 @@ +# Version 11.2.0 +v11.2.0 fixes a lot of bugs we encountered along the 11.1.0 release, as well as support for new features such as Message Attachments and UserGuildSettings. +See [the changelog](https://github.com/hydrabolt/discord.js/releases/tag/11.2.0) for a full list of changes, including information about deprecations. + # Version 11.1.0 v11.1.0 features improved voice and gateway stability, as well as support for new features such as audit logs and searching for messages. See [the changelog](https://github.com/hydrabolt/discord.js/releases/tag/11.1.0) for a full list of changes, including @@ -118,9 +122,9 @@ The guild parameter that has been dropped from the guild-related events can stil ## Dates and timestamps All dates/timestamps on the structures have been refactored to have a consistent naming scheme and availability. -All of them are named similarly to this: -**Date:** `Message.createdAt` -**Timestamp:** `Message.createdTimestamp` +All of them are named similarly to this: +**Date:** `Message.createdAt` +**Timestamp:** `Message.createdTimestamp` See the docs for each structure to see which date/timestamps are available on them. @@ -149,7 +153,7 @@ A couple more important details: * `Client.servers.length` ==> `client.guilds.size` (all instances of `server` are now `guild`) ## No more callbacks! -Version 9 eschews callbacks in favour of Promises. This means all code relying on callbacks must be changed. +Version 9 eschews callbacks in favour of Promises. This means all code relying on callbacks must be changed. For example, the following code: ```js diff --git a/docs/general/welcome.md b/docs/general/welcome.md index 5a11c91e5..d93f83924 100644 --- a/docs/general/welcome.md +++ b/docs/general/welcome.md @@ -17,8 +17,8 @@ # Welcome! -Welcome to the discord.js v11.1.0 documentation. -v11.1.0 features improved voice and gateway stability, as well as support for new features such as audit logs and searching for messages. +Welcome to the discord.js v11.2.0 documentation. +v11.2.0 fixes a lot of bugs we encountered along the 11.1.0 release, as well as support for new features such as Message Attachments and UserGuildSettings. ## About discord.js is a powerful [node.js](https://nodejs.org) module that allows you to interact with the @@ -30,11 +30,11 @@ discord.js is a powerful [node.js](https://nodejs.org) module that allows you to - 100% coverage of the Discord API ## Installation -**Node.js 6.0.0 or newer is required.** +**Node.js 6.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` +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` ### Audio engines @@ -79,8 +79,8 @@ client.login('your token'); ## Contributing Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the -[documentation](https://discord.js.org/#/docs). -See [the contribution guide](https://github.com/hydrabolt/discord.js/blob/master/CONTRIBUTING.md) if you'd like to submit a PR. +[documentation](https://discord.js.org/#/docs). +See [the contribution guide](https://github.com/hydrabolt/discord.js/blob/master/.github/CONTRIBUTING.md) if you'd like to submit a PR. ## Help If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle diff --git a/docs/topics/voice.md b/docs/topics/voice.md index fc9e81237..dc70eb0e9 100644 --- a/docs/topics/voice.md +++ b/docs/topics/voice.md @@ -4,7 +4,7 @@ Voice in discord.js can be used for many things, such as music bots, recording o In discord.js, you can use voice by connecting to a `VoiceChannel` to obtain a `VoiceConnection`, where you can start streaming and receiving audio. To get started, make sure you have: -* ffmpeg - `npm install --global ffmpeg-binaries` +* ffmpeg - `npm install ffmpeg-binaries` * an opus encoder, choose one from below: * `npm install opusscript` * `npm install node-opus` diff --git a/docs/topics/web.md b/docs/topics/web.md index 589382e17..0adb79526 100644 --- a/docs/topics/web.md +++ b/docs/topics/web.md @@ -30,7 +30,7 @@ The usage of the API isn't any different from using it in Node.js. client.on('message', msg => { const guildTag = msg.channel.type === 'text' ? `[${msg.guild.name}]` : '[DM]'; const channelTag = msg.channel.type === 'text' ? `[#${msg.channel.name}]` : ''; - console.log(`${guildTag}${channelTag} ${msg.author.username}#${msg.author.discriminator}: ${msg.content}`); + console.log(`${guildTag}${channelTag} ${msg.author.tag}: ${msg.content}`); }); client.login('some crazy token'); diff --git a/package.json b/package.json index e0d48d083..e536e7287 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "discord.js", - "version": "11.1.0", + "version": "11.2.0", "description": "A powerful library for interacting with the Discord API", "main": "./src/index", "types": "./typings/index.d.ts", @@ -34,26 +34,26 @@ "dependencies": { "long": "^3.2.0", "prism-media": "^0.0.1", - "snekfetch": "^3.1.0", - "tweetnacl": "^0.14.0", - "ws": "^2.0.0" + "snekfetch": "^3.3.0", + "tweetnacl": "^1.0.0", + "ws": "^3.1.0" }, "peerDependencies": { - "bufferutil": "^3.0.0", + "bufferutil": "^3.0.2", "erlpack": "hammerandchisel/erlpack", - "node-opus": "^0.2.5", + "node-opus": "^0.2.6", "opusscript": "^0.0.3", "sodium": "^2.0.1", - "libsodium-wrappers": "^0.5.1", - "uws": "^0.14.1" + "libsodium-wrappers": "^0.5.4", + "uws": "^0.14.5" }, "devDependencies": { - "@types/node": "^7.0.0", + "@types/node": "^7.0.43", "discord.js-docgen": "hydrabolt/discord.js-docgen", - "eslint": "^3.19.0", - "parallel-webpack": "^1.6.0", - "uglify-js": "mishoo/UglifyJS2#harmony", - "webpack": "^2.2.0" + "eslint": "^4.6.0", + "parallel-webpack": "^2.1.0", + "uglifyjs-webpack-plugin": "^1.0.0-beta.1", + "webpack": "^3.5.5" }, "engines": { "node": ">=6.0.0" diff --git a/src/client/Client.js b/src/client/Client.js index 861becb2c..54790ac7a 100644 --- a/src/client/Client.js +++ b/src/client/Client.js @@ -1,5 +1,4 @@ -const os = require('os'); -const EventEmitter = require('events').EventEmitter; +const EventEmitter = require('events'); const Constants = require('../util/Constants'); const Permissions = require('../util/Permissions'); const Util = require('../util/Util'); @@ -120,6 +119,7 @@ class Client extends EventEmitter { */ this.presences = new Collection(); + Object.defineProperty(this, 'token', { writable: true }); if (!this.token && 'CLIENT_TOKEN' in process.env) { /** * Authorization token for the logged in user/bot @@ -249,7 +249,7 @@ class Client extends EventEmitter { * @readonly */ get browser() { - return os.platform() === 'browser'; + return typeof window !== 'undefined'; } /** @@ -419,9 +419,9 @@ class Client extends EventEmitter { */ setTimeout(fn, delay, ...args) { const timeout = setTimeout(() => { - fn(); + fn(...args); this._timeouts.delete(timeout); - }, delay, ...args); + }, delay); this._timeouts.add(timeout); return timeout; } diff --git a/src/client/ClientDataResolver.js b/src/client/ClientDataResolver.js index 04d6b0b01..c725792b7 100644 --- a/src/client/ClientDataResolver.js +++ b/src/client/ClientDataResolver.js @@ -28,7 +28,7 @@ class ClientDataResolver { /** * Data that resolves to give a User object. This can be: * * A User object - * * A user ID + * * A Snowflake * * A Message object (resolves to the message author) * * A Guild object (owner of the guild) * * A GuildMember object @@ -65,7 +65,7 @@ class ClientDataResolver { /** * Data that resolves to give a Guild object. This can be: * * A Guild object - * * A Guild ID + * * A Snowflake * @typedef {Guild|Snowflake} GuildResolvable */ @@ -106,7 +106,7 @@ class ClientDataResolver { * * A Channel object * * A Message object (the channel the message was sent in) * * A Guild object (the #general channel) - * * A channel ID + * * A Snowflake * @typedef {Channel|Guild|Message|Snowflake} ChannelResolvable */ @@ -174,6 +174,20 @@ class ClientDataResolver { return String(data); } + + /** + * Resolves a Base64Resolvable, a string, or a BufferResolvable to a Base 64 image. + * @param {BufferResolvable|Base64Resolvable} image The image to be resolved + * @returns {Promise} + */ + resolveImage(image) { + if (!image) return Promise.resolve(null); + if (typeof image === 'string' && image.startsWith('data:')) { + return Promise.resolve(image); + } + return this.resolveFile(image).then(this.resolveBase64); + } + /** * Data that resolves to give a Base64 string, typically for image uploading. This can be: * * A Buffer @@ -192,19 +206,25 @@ class ClientDataResolver { } /** - * Data that can be resolved to give a Buffer. This can be: - * * A Buffer - * * The path to a local file - * * A URL - * @typedef {string|Buffer} BufferResolvable - */ + * Data that can be resolved to give a Buffer. This can be: + * * A Buffer + * * The path to a local file + * * A URL + * * A Stream + * @typedef {string|Buffer} BufferResolvable + */ /** - * Resolves a BufferResolvable to a Buffer. - * @param {BufferResolvable} resource The buffer resolvable to resolve - * @returns {Promise} - */ - resolveBuffer(resource) { + * @external Stream + * @see {@link https://nodejs.org/api/stream.html} + */ + + /** + * Resolves a BufferResolvable to a Buffer. + * @param {BufferResolvable|Stream} resource The buffer or stream resolvable to resolve + * @returns {Promise} + */ + resolveFile(resource) { if (resource instanceof Buffer) return Promise.resolve(resource); if (this.client.browser && resource instanceof ArrayBuffer) return Promise.resolve(convertToBuffer(resource)); @@ -212,11 +232,11 @@ class ClientDataResolver { return new Promise((resolve, reject) => { if (/^https?:\/\//.test(resource)) { snekfetch.get(resource) - .end((err, res) => { - if (err) return reject(err); - if (!(res.body instanceof Buffer)) return reject(new TypeError('The response body isn\'t a Buffer.')); - return resolve(res.body); - }); + .end((err, res) => { + if (err) return reject(err); + if (!(res.body instanceof Buffer)) return reject(new TypeError('The response body isn\'t a Buffer.')); + return resolve(res.body); + }); } else { const file = path.resolve(resource); fs.stat(file, (err, stats) => { @@ -229,6 +249,13 @@ class ClientDataResolver { }); } }); + } else if (resource.pipe && typeof resource.pipe === 'function') { + return new Promise((resolve, reject) => { + const buffers = []; + resource.once('error', reject); + resource.on('data', data => buffers.push(data)); + resource.once('end', () => resolve(Buffer.concat(buffers))); + }); } return Promise.reject(new TypeError('The resource must be a string or Buffer.')); diff --git a/src/client/ClientManager.js b/src/client/ClientManager.js index 551e20dc1..538411079 100644 --- a/src/client/ClientManager.js +++ b/src/client/ClientManager.js @@ -43,7 +43,7 @@ class ClientManager { const gateway = `${res.url}/?v=${protocolVersion}&encoding=${WebSocketConnection.ENCODING}`; this.client.emit(Constants.Events.DEBUG, `Using gateway ${gateway}`); this.client.ws.connect(gateway); - this.client.ws.once('close', event => { + this.client.ws.connection.once('close', event => { if (event.code === 4004) reject(new Error(Constants.Errors.BAD_LOGIN)); if (event.code === 4010) reject(new Error(Constants.Errors.INVALID_SHARD)); if (event.code === 4011) reject(new Error(Constants.Errors.SHARDING_REQUIRED)); diff --git a/src/client/WebhookClient.js b/src/client/WebhookClient.js index f89c8f973..99291b550 100644 --- a/src/client/WebhookClient.js +++ b/src/client/WebhookClient.js @@ -65,9 +65,9 @@ class WebhookClient extends Webhook { */ setTimeout(fn, delay, ...args) { const timeout = setTimeout(() => { - fn(); + fn(...args); this._timeouts.delete(timeout); - }, delay, ...args); + }, delay); this._timeouts.add(timeout); return timeout; } diff --git a/src/client/rest/APIRequest.js b/src/client/rest/APIRequest.js index 518017ae3..3492fa7a8 100644 --- a/src/client/rest/APIRequest.js +++ b/src/client/rest/APIRequest.js @@ -2,7 +2,7 @@ const snekfetch = require('snekfetch'); const Constants = require('../../util/Constants'); class APIRequest { - constructor(rest, method, path, auth, data, files) { + constructor(rest, method, path, auth, data, files, reason) { this.rest = rest; this.client = rest.client; this.method = method; @@ -11,6 +11,7 @@ class APIRequest { this.data = data; this.files = files; this.route = this.getRoute(this.path); + this.reason = reason; } getRoute(url) { @@ -36,6 +37,7 @@ class APIRequest { const API = `${this.client.options.http.host}/api/v${this.client.options.http.version}`; const request = snekfetch[this.method](`${API}${this.path}`); if (this.auth) request.set('Authorization', this.getAuth()); + if (this.reason) request.set('X-Audit-Log-Reason', encodeURIComponent(this.reason)); if (!this.rest.client.browser) request.set('User-Agent', this.rest.userAgentManager.userAgent); if (this.files) { for (const file of this.files) if (file && file.file) request.attach(file.name, file.file, file.name); diff --git a/src/client/rest/DiscordAPIError.js b/src/client/rest/DiscordAPIError.js index a28e3a8a5..1b9c194d9 100644 --- a/src/client/rest/DiscordAPIError.js +++ b/src/client/rest/DiscordAPIError.js @@ -1,12 +1,19 @@ /** * Represents an error from the Discord API. + * @extends Error */ class DiscordAPIError extends Error { - constructor(error) { + constructor(path, error) { super(); - const flattened = error.errors ? `\n${this.constructor.flattenErrors(error.errors).join('\n')}` : ''; + const flattened = this.constructor.flattenErrors(error.errors || error).join('\n'); this.name = 'DiscordAPIError'; - this.message = `${error.message}${flattened}`; + this.message = error.message && flattened ? `${error.message}\n${flattened}` : error.message || flattened; + + /** + * The path of the request relative to the HTTP endpoint + * @type {string} + */ + this.path = path; /** * HTTP error code returned by Discord @@ -18,15 +25,23 @@ class DiscordAPIError extends Error { /** * Flattens an errors object returned from the API into an array. * @param {Object} obj Discord errors object - * @param {string} [key] idklol + * @param {string} [key] Used internally to determine key names of nested fields * @returns {string[]} + * @private */ static flattenErrors(obj, key = '') { let messages = []; + for (const k of Object.keys(obj)) { + if (k === 'message') continue; const newKey = key ? isNaN(k) ? `${key}.${k}` : `${key}[${k}]` : k; + if (obj[k]._errors) { messages.push(`${newKey}: ${obj[k]._errors.map(e => e.message).join(' ')}`); + } else if (obj[k].code || obj[k].message) { + messages.push(`${obj[k].code ? `${obj[k].code}: ` : ''}: ${obj[k].message}`.trim()); + } else if (typeof obj[k] === 'string') { + messages.push(obj[k]); } else { messages = messages.concat(this.flattenErrors(obj[k], newKey)); } diff --git a/src/client/rest/RESTManager.js b/src/client/rest/RESTManager.js index 512b3063e..37d5a9599 100644 --- a/src/client/rest/RESTManager.js +++ b/src/client/rest/RESTManager.js @@ -42,8 +42,8 @@ class RESTManager { } } - makeRequest(method, url, auth, data, file) { - const apiRequest = new APIRequest(this, method, url, auth, data, file); + makeRequest(method, url, auth, data, file, reason) { + const apiRequest = new APIRequest(this, method, url, auth, data, file, reason); if (!this.handlers[apiRequest.route]) { const RequestHandlerType = this.getRequestHandler(); this.handlers[apiRequest.route] = new RequestHandlerType(this, apiRequest.route); diff --git a/src/client/rest/RESTMethods.js b/src/client/rest/RESTMethods.js index 69d29ecba..c621eead4 100644 --- a/src/client/rest/RESTMethods.js +++ b/src/client/rest/RESTMethods.js @@ -102,12 +102,12 @@ class RESTMethods { if (content instanceof Array) { const messages = []; (function sendChunk(list, index) { - const options = index === list.length ? { tts, embed } : { tts }; - chan.send(list[index], options, index === list.length ? files : null).then(message => { + const options = index === list.length - 1 ? { tts, embed, files } : { tts }; + chan.send(list[index], options).then(message => { messages.push(message); if (index >= list.length - 1) return resolve(messages); return sendChunk(list, ++index); - }); + }).catch(reject); }(content, 0)); } else { this.rest.makeRequest('post', Endpoints.Channel(chan).messages, true, { @@ -227,6 +227,7 @@ class RESTMethods { embed_type: options.embedType, attachment_filename: options.attachmentFilename, attachment_extension: options.attachmentExtension, + include_nsfw: options.nsfw, }; for (const key in options) if (options[key] === undefined) delete options[key]; @@ -251,13 +252,13 @@ class RESTMethods { }); } - createChannel(guild, channelName, channelType, overwrites) { + createChannel(guild, channelName, channelType, overwrites, reason) { if (overwrites instanceof Collection) overwrites = overwrites.array(); return this.rest.makeRequest('post', Endpoints.Guild(guild).channels, true, { name: channelName, type: channelType, permission_overwrites: overwrites, - }).then(data => this.client.actions.ChannelCreate.handle(data).channel); + }, undefined, reason).then(data => this.client.actions.ChannelCreate.handle(data).channel); } createDM(recipient) { @@ -284,29 +285,42 @@ class RESTMethods { .then(() => channel); } + removeUserFromGroupDM(channel, userId) { + return this.rest.makeRequest('delete', Endpoints.Channel(channel).Recipient(userId), true) + .then(() => channel); + } + + updateGroupDMChannel(channel, _data) { + const data = {}; + data.name = _data.name; + data.icon = _data.icon; + return this.rest.makeRequest('patch', Endpoints.Channel(channel), true, data).then(() => channel); + } + getExistingDM(recipient) { return this.client.channels.find(channel => channel.recipient && channel.recipient.id === recipient.id ); } - deleteChannel(channel) { + deleteChannel(channel, reason) { if (channel instanceof User || channel instanceof GuildMember) channel = this.getExistingDM(channel); if (!channel) return Promise.reject(new Error('No channel to delete.')); - return this.rest.makeRequest('delete', Endpoints.Channel(channel), true).then(data => { - data.id = channel.id; - return this.client.actions.ChannelDelete.handle(data).channel; - }); + return this.rest.makeRequest('delete', Endpoints.Channel(channel), true, undefined, undefined, reason) + .then(data => { + data.id = channel.id; + return this.client.actions.ChannelDelete.handle(data).channel; + }); } - updateChannel(channel, _data) { + updateChannel(channel, _data, reason) { const data = {}; data.name = (_data.name || channel.name).trim(); data.topic = _data.topic || channel.topic; data.position = _data.position || channel.position; data.bitrate = _data.bitrate || channel.bitrate; data.user_limit = _data.userLimit || channel.userLimit; - return this.rest.makeRequest('patch', Endpoints.Channel(channel), true, data).then(newData => + return this.rest.makeRequest('patch', Endpoints.Channel(channel), true, data, undefined, reason).then(newData => this.client.actions.ChannelUpdate.handle(newData).updated ); } @@ -361,7 +375,7 @@ class RESTMethods { const user = this.client.user; const data = {}; data.username = _data.username || user.username; - data.avatar = this.client.resolver.resolveBase64(_data.avatar) || user.avatar; + data.avatar = typeof _data.avatar === 'undefined' ? user.avatar : this.client.resolver.resolveBase64(_data.avatar); if (!user.bot) { data.email = _data.email || user.email; data.password = password; @@ -372,58 +386,57 @@ class RESTMethods { ); } - updateGuild(guild, _data) { - const data = {}; - if (_data.name) data.name = _data.name; - if (_data.region) data.region = _data.region; - if (_data.verificationLevel) data.verification_level = Number(_data.verificationLevel); - if (_data.afkChannel) data.afk_channel_id = this.client.resolver.resolveChannel(_data.afkChannel).id; - if (_data.afkTimeout) data.afk_timeout = Number(_data.afkTimeout); - if (_data.icon) data.icon = this.client.resolver.resolveBase64(_data.icon); - if (_data.owner) data.owner_id = this.client.resolver.resolveUser(_data.owner).id; - if (_data.splash) data.splash = this.client.resolver.resolveBase64(_data.splash); - return this.rest.makeRequest('patch', Endpoints.Guild(guild), true, data).then(newData => + updateGuild(guild, data, reason) { + return this.rest.makeRequest('patch', Endpoints.Guild(guild), true, data, undefined, reason).then(newData => this.client.actions.GuildUpdate.handle(newData).updated ); } kickGuildMember(guild, member, reason) { - const url = `${Endpoints.Guild(guild).Member(member)}?reason=${reason}`; - return this.rest.makeRequest('delete', url, true).then(() => - this.client.actions.GuildMemberRemove.handle({ - guild_id: guild.id, - user: member.user, - }).member - ); + return this.rest.makeRequest( + 'delete', Endpoints.Guild(guild).Member(member), true, + undefined, undefined, reason) + .then(() => + this.client.actions.GuildMemberRemove.handle({ + guild_id: guild.id, + user: member.user, + }).member + ); } - createGuildRole(guild, data) { + createGuildRole(guild, data, reason) { if (data.color) data.color = this.client.resolver.resolveColor(data.color); if (data.permissions) data.permissions = Permissions.resolve(data.permissions); - return this.rest.makeRequest('post', Endpoints.Guild(guild).roles, true, data).then(role => - this.client.actions.GuildRoleCreate.handle({ + return this.rest.makeRequest('post', Endpoints.Guild(guild).roles, true, data, undefined, reason).then(r => { + const { role } = this.client.actions.GuildRoleCreate.handle({ guild_id: guild.id, - role, - }).role - ); + role: r, + }); + if (data.position) return role.setPosition(data.position, reason); + return role; + }); } - deleteGuildRole(role) { - return this.rest.makeRequest('delete', Endpoints.Guild(role.guild).Role(role.id), true).then(() => - this.client.actions.GuildRoleDelete.handle({ - guild_id: role.guild.id, - role_id: role.id, - }).role - ); + deleteGuildRole(role, reason) { + return this.rest.makeRequest( + 'delete', Endpoints.Guild(role.guild).Role(role.id), true, + undefined, undefined, reason) + .then(() => + this.client.actions.GuildRoleDelete.handle({ + guild_id: role.guild.id, + role_id: role.id, + }).role + ); } setChannelOverwrite(channel, payload) { return this.rest.makeRequest('put', `${Endpoints.Channel(channel).permissions}/${payload.id}`, true, payload); } - deletePermissionOverwrites(overwrite) { + deletePermissionOverwrites(overwrite, reason) { return this.rest.makeRequest( - 'delete', `${Endpoints.Channel(overwrite.channel).permissions}/${overwrite.id}`, true + 'delete', `${Endpoints.Channel(overwrite.channel).permissions}/${overwrite.id}`, + true, undefined, undefined, reason ).then(() => overwrite); } @@ -464,8 +477,11 @@ class RESTMethods { }); } - updateGuildMember(member, data) { - if (data.channel) data.channel_id = this.client.resolver.resolveChannel(data.channel).id; + updateGuildMember(member, data, reason) { + if (data.channel) { + data.channel_id = this.client.resolver.resolveChannel(data.channel).id; + data.channel = null; + } if (data.roles) data.roles = data.roles.map(role => role instanceof Role ? role.id : role); let endpoint = Endpoints.Member(member); @@ -477,12 +493,12 @@ class RESTMethods { } } - return this.rest.makeRequest('patch', endpoint, true, data).then(newData => + return this.rest.makeRequest('patch', endpoint, true, data, undefined, reason).then(newData => member.guild._updateMember(member, newData).mem ); } - addMemberRole(member, role) { + addMemberRole(member, role, reason) { return new Promise((resolve, reject) => { if (member._roles.includes(role.id)) return resolve(member); @@ -497,15 +513,16 @@ class RESTMethods { const timeout = this.client.setTimeout(() => this.client.removeListener(Constants.Events.GUILD_MEMBER_UPDATE, listener), 10e3); - return this.rest.makeRequest('put', Endpoints.Member(member).Role(role.id), true).catch(err => { - this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); - this.client.clearTimeout(timeout); - reject(err); - }); + return this.rest.makeRequest('put', Endpoints.Member(member).Role(role.id), true, undefined, undefined, reason) + .catch(err => { + this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); + this.client.clearTimeout(timeout); + reject(err); + }); }); } - removeMemberRole(member, role) { + removeMemberRole(member, role, reason) { return new Promise((resolve, reject) => { if (!member._roles.includes(role.id)) return resolve(member); @@ -520,11 +537,12 @@ class RESTMethods { const timeout = this.client.setTimeout(() => this.client.removeListener(Constants.Events.GUILD_MEMBER_UPDATE, listener), 10e3); - return this.rest.makeRequest('delete', Endpoints.Member(member).Role(role.id), true).catch(err => { - this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); - this.client.clearTimeout(timeout); - reject(err); - }); + return this.rest.makeRequest('delete', Endpoints.Member(member).Role(role.id), true, undefined, undefined, reason) + .catch(err => { + this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); + this.client.clearTimeout(timeout); + reject(err); + }); }); } @@ -548,7 +566,7 @@ class RESTMethods { }); } - unbanGuildMember(guild, member) { + unbanGuildMember(guild, member, reason) { return new Promise((resolve, reject) => { const id = this.client.resolver.resolveUserID(member); if (!id) throw new Error('Couldn\'t resolve the user ID to unban.'); @@ -567,11 +585,12 @@ class RESTMethods { reject(new Error('Took too long to receive the ban remove event.')); }, 10000); - this.rest.makeRequest('delete', `${Endpoints.Guild(guild).bans}/${id}`, true).catch(err => { - this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); - this.client.clearTimeout(timeout); - reject(err); - }); + this.rest.makeRequest('delete', `${Endpoints.Guild(guild).bans}/${id}`, true, undefined, undefined, reason) + .catch(err => { + this.client.removeListener(Constants.Events.GUILD_BAN_REMOVE, listener); + this.client.clearTimeout(timeout); + reject(err); + }); }); } @@ -587,7 +606,7 @@ class RESTMethods { ); } - updateGuildRole(role, _data) { + updateGuildRole(role, _data, reason) { const data = {}; data.name = _data.name || role.name; data.position = typeof _data.position !== 'undefined' ? _data.position : role.position; @@ -598,12 +617,13 @@ class RESTMethods { if (_data.permissions) data.permissions = Permissions.resolve(_data.permissions); else data.permissions = role.permissions; - return this.rest.makeRequest('patch', Endpoints.Guild(role.guild).Role(role.id), true, data).then(_role => - this.client.actions.GuildRoleUpdate.handle({ - role: _role, - guild_id: role.guild.id, - }).updated - ); + return this.rest.makeRequest('patch', Endpoints.Guild(role.guild).Role(role.id), true, data, undefined, reason) + .then(_role => + this.client.actions.GuildRoleUpdate.handle({ + role: _role, + guild_id: role.guild.id, + }).updated + ); } pinMessage(message) { @@ -620,17 +640,19 @@ class RESTMethods { return this.rest.makeRequest('get', Endpoints.Channel(channel).pins, true); } - createChannelInvite(channel, options) { + createChannelInvite(channel, options, reason) { const payload = {}; payload.temporary = options.temporary; payload.max_age = options.maxAge; payload.max_uses = options.maxUses; - return this.rest.makeRequest('post', Endpoints.Channel(channel).invites, true, payload) + payload.unique = options.unique; + return this.rest.makeRequest('post', Endpoints.Channel(channel).invites, true, payload, undefined, reason) .then(invite => new Invite(this.client, invite)); } - deleteInvite(invite) { - return this.rest.makeRequest('delete', Endpoints.Invite(invite.code), true).then(() => invite); + deleteInvite(invite, reason) { + return this.rest.makeRequest('delete', Endpoints.Invite(invite.code), true, undefined, undefined, reason) + .then(() => invite); } getInvite(code) { @@ -650,28 +672,31 @@ class RESTMethods { }); } - pruneGuildMembers(guild, days, dry) { - return this.rest.makeRequest(dry ? 'get' : 'post', `${Endpoints.Guild(guild).prune}?days=${days}`, true) + pruneGuildMembers(guild, days, dry, reason) { + return this.rest.makeRequest(dry ? + 'get' : + 'post', + `${Endpoints.Guild(guild).prune}?days=${days}`, true, undefined, undefined, reason) .then(data => data.pruned); } - createEmoji(guild, image, name, roles) { + createEmoji(guild, image, name, roles, reason) { const data = { image, name }; if (roles) data.roles = roles.map(r => r.id ? r.id : r); - return this.rest.makeRequest('post', Endpoints.Guild(guild).emojis, true, data) + return this.rest.makeRequest('post', Endpoints.Guild(guild).emojis, true, data, undefined, reason) .then(emoji => this.client.actions.GuildEmojiCreate.handle(guild, emoji).emoji); } - updateEmoji(emoji, _data) { + updateEmoji(emoji, _data, reason) { const data = {}; if (_data.name) data.name = _data.name; if (_data.roles) data.roles = _data.roles.map(r => r.id ? r.id : r); - return this.rest.makeRequest('patch', Endpoints.Guild(emoji.guild).Emoji(emoji.id), true, data) + return this.rest.makeRequest('patch', Endpoints.Guild(emoji.guild).Emoji(emoji.id), true, data, undefined, reason) .then(newEmoji => this.client.actions.GuildEmojiUpdate.handle(emoji, newEmoji).emoji); } - deleteEmoji(emoji) { - return this.rest.makeRequest('delete', Endpoints.Guild(emoji.guild).Emoji(emoji.id), true) + deleteEmoji(emoji, reason) { + return this.rest.makeRequest('delete', Endpoints.Guild(emoji.guild).Emoji(emoji.id), true, undefined, reason) .then(() => this.client.actions.GuildEmojiDelete.handle(emoji).data); } @@ -714,8 +739,8 @@ class RESTMethods { }); } - createWebhook(channel, name, avatar) { - return this.rest.makeRequest('post', Endpoints.Channel(channel).webhooks, true, { name, avatar }) + createWebhook(channel, name, avatar, reason) { + return this.rest.makeRequest('post', Endpoints.Channel(channel).webhooks, true, { name, avatar }, undefined, reason) .then(data => new Webhook(this.client, data)); } @@ -730,25 +755,36 @@ class RESTMethods { }); } - deleteWebhook(webhook) { - return this.rest.makeRequest('delete', Endpoints.Webhook(webhook.id, webhook.token), false); + deleteWebhook(webhook, reason) { + return this.rest.makeRequest( + 'delete', Endpoints.Webhook(webhook.id, webhook.token), + false, undefined, undefined, reason); } - sendWebhookMessage(webhook, content, { avatarURL, tts, disableEveryone, embeds, username } = {}, file = null) { - username = username || webhook.name; - if (typeof content !== 'undefined') content = this.client.resolver.resolveString(content); - if (content) { - if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) { - content = content.replace(/@(everyone|here)/g, '@\u200b$1'); + sendWebhookMessage(webhook, content, { avatarURL, tts, embeds, username } = {}, files = null) { + return new Promise((resolve, reject) => { + username = username || webhook.name; + + if (content instanceof Array) { + const messages = []; + (function sendChunk(list, index) { + const options = index === list.length - 1 ? { tts, embeds, files } : { tts }; + webhook.send(list[index], options).then(message => { + messages.push(message); + if (index >= list.length - 1) return resolve(messages); + return sendChunk(list, ++index); + }).catch(reject); + }(content, 0)); + } else { + this.rest.makeRequest('post', `${Endpoints.Webhook(webhook.id, webhook.token)}?wait=true`, false, { + username, + avatar_url: avatarURL, + content, + tts, + embeds, + }, files).then(resolve, reject); } - } - return this.rest.makeRequest('post', `${Endpoints.Webhook(webhook.id, webhook.token)}?wait=true`, false, { - username, - avatar_url: avatarURL, - content, - tts, - embeds, - }, file); + }); } sendSlackWebhookMessage(webhook, body) { @@ -763,12 +799,13 @@ class RESTMethods { ); } - fetchMeMentions(options) { - if (options.guild) options.guild = options.guild.id ? options.guild.id : options.guild; + fetchMentions(options) { + if (options.guild instanceof Guild) options.guild = options.guild.id; + Util.mergeDefault({ limit: 25, roles: true, everyone: true, guild: null }, options); + return this.rest.makeRequest( - 'get', - Endpoints.User('@me').mentions(options.limit, options.roles, options.everyone, options.guild) - ).then(res => res.body.map(m => new Message(this.client.channels.get(m.channel_id), m, this.client))); + 'get', Endpoints.User('@me').Mentions(options.limit, options.roles, options.everyone, options.guild), true + ).then(data => data.map(m => new Message(this.client.channels.get(m.channel_id), m, this.client))); } addFriend(user) { @@ -833,7 +870,7 @@ class RESTMethods { 'put', Endpoints.Message(message).Reaction(emoji).User('@me'), true ).then(() => message._addReaction(Util.parseEmoji(emoji), message.client.user) - ); + ); } removeMessageReaction(message, emoji, userID) { @@ -864,7 +901,8 @@ class RESTMethods { } resetApplication(id) { - return this.rest.makeRequest('post', Endpoints.OAUTH2.Application(id).reset, true) + return this.rest.makeRequest('post', Endpoints.OAUTH2.Application(id).resetToken, true) + .then(() => this.rest.makeRequest('post', Endpoints.OAUTH2.Application(id).resetSecret, true)) .then(app => new OAuth2Application(this.client, app)); } @@ -894,6 +932,10 @@ class RESTMethods { patchUserSettings(data) { return this.rest.makeRequest('patch', Constants.Endpoints.User('@me').settings, true, data); } + + patchClientUserGuildSettings(guildID, data) { + return this.rest.makeRequest('patch', Constants.Endpoints.User('@me').Guild(guildID).settings, true, data); + } } module.exports = RESTMethods; diff --git a/src/client/rest/RequestHandlers/Burst.js b/src/client/rest/RequestHandlers/Burst.js index e5a160246..1f941af31 100644 --- a/src/client/rest/RequestHandlers/Burst.js +++ b/src/client/rest/RequestHandlers/Burst.js @@ -40,8 +40,14 @@ class BurstRequestHandler extends RequestHandler { this.handle(); this.resetTimeout = null; }, Number(res.headers['retry-after']) + this.client.options.restTimeOffset); + } else if (err.status >= 500 && err.status < 600) { + this.queue.unshift(item); + this.resetTimeout = this.client.setTimeout(() => { + this.handle(); + this.resetTimeout = null; + }, 1e3 + this.client.options.restTimeOffset); } else { - item.reject(err.status === 400 ? new DiscordAPIError(res.body) : err); + item.reject(err.status >= 400 && err.status < 500 ? new DiscordAPIError(res.request.path, res.body) : err); this.handle(); } } else { diff --git a/src/client/rest/RequestHandlers/Sequential.js b/src/client/rest/RequestHandlers/Sequential.js index 04f805237..5f026f7bb 100644 --- a/src/client/rest/RequestHandlers/Sequential.js +++ b/src/client/rest/RequestHandlers/Sequential.js @@ -64,8 +64,11 @@ class SequentialRequestHandler extends RequestHandler { resolve(); }, Number(res.headers['retry-after']) + this.restManager.client.options.restTimeOffset); if (res.headers['x-ratelimit-global']) this.globalLimit = true; + } else if (err.status >= 500 && err.status < 600) { + this.queue.unshift(item); + this.restManager.client.setTimeout(resolve, 1e3 + this.client.options.restTimeOffset); } else { - item.reject(err.status >= 400 && err.status < 500 ? new DiscordAPIError(res.body) : err); + item.reject(err.status >= 400 && err.status < 500 ? new DiscordAPIError(res.request.path, res.body) : err); resolve(err); } } else { diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js index b80efaf4a..3a78d87a7 100644 --- a/src/client/voice/VoiceBroadcast.js +++ b/src/client/voice/VoiceBroadcast.js @@ -18,7 +18,7 @@ const ffmpegArguments = [ * ```js * const broadcast = client.createVoiceBroadcast(); * broadcast.playFile('./music.mp3'); - * // play "music.mp3" in all voice connections that the client is in + * // Play "music.mp3" in all voice connections that the client is in * for (const connection of client.voiceConnections.values()) { * connection.playBroadcast(broadcast); * } @@ -136,15 +136,15 @@ class VoiceBroadcast extends VolumeInterface { * const broadcast = client.createVoiceBroadcast(); * * voiceChannel.join() - * .then(connection => { - * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', { filter : 'audioonly' }); - * broadcast.playStream(stream); - * const dispatcher = connection.playBroadcast(broadcast); - * }) - * .catch(console.error); + * .then(connection => { + * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', { filter : 'audioonly' }); + * broadcast.playStream(stream); + * const dispatcher = connection.playBroadcast(broadcast); + * }) + * .catch(console.error); */ - playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes, stream }; + playStream(stream, options = {}) { + this.setVolume(options.volume || 1); return this._playTranscodable(stream, options); } @@ -158,25 +158,23 @@ class VoiceBroadcast extends VolumeInterface { * const broadcast = client.createVoiceBroadcast(); * * voiceChannel.join() - * .then(connection => { - * broadcast.playFile('C:/Users/Discord/Desktop/music.mp3'); - * const dispatcher = connection.playBroadcast(broadcast); - * }) - * .catch(console.error); + * .then(connection => { + * broadcast.playFile('C:/Users/Discord/Desktop/music.mp3'); + * const dispatcher = connection.playBroadcast(broadcast); + * }) + * .catch(console.error); */ - playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) { - const options = { seek, volume, passes }; + playFile(file, options = {}) { + this.setVolume(options.volume || 1); return this._playTranscodable(`file:${file}`, options); } _playTranscodable(media, options) { - OpusEncoders.guaranteeOpusEngine(); - this.killCurrentTranscoder(); const transcoder = this.prism.transcode({ type: 'ffmpeg', media, - ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek)]), + ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek || 0)]), }); /** * Emitted whenever an error occurs. @@ -206,31 +204,28 @@ class VoiceBroadcast extends VolumeInterface { } /** - * Plays a stream of 16-bit signed stereo PCM at 48KHz. + * Plays a stream of 16-bit signed stereo PCM. * @param {ReadableStream} stream The audio stream to play * @param {StreamOptions} [options] Options for playing the stream * @returns {VoiceBroadcast} */ - playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - OpusEncoders.guaranteeOpusEngine(); - + playConvertedStream(stream, options = {}) { this.killCurrentTranscoder(); - const options = { seek, volume, passes, stream }; - this.currentTranscoder = { options }; + this.setVolume(options.volume || 1); + this.currentTranscoder = { options: { stream } }; stream.once('readable', () => this._startPlaying()); return this; } /** - * Plays an Opus encoded stream at 48KHz. + * Plays an Opus encoded stream. * Note that inline volume is not compatible with this method. * @param {ReadableStream} stream The Opus audio stream to play * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} */ - playOpusStream(stream, { seek = 0, passes = 1 } = {}) { - const options = { seek, passes, stream }; - this.currentTranscoder = { options, opus: true }; + playOpusStream(stream) { + this.currentTranscoder = { options: { stream }, opus: true }; stream.once('readable', () => this._startPlaying()); return this; } @@ -241,10 +236,9 @@ class VoiceBroadcast extends VolumeInterface { * @param {StreamOptions} [options] Options for playing the stream * @returns {VoiceBroadcast} */ - playArbitraryInput(input, { seek = 0, volume = 1, passes = 1 } = {}) { - this.guaranteeOpusEngine(); - - const options = { seek, volume, passes, input }; + playArbitraryInput(input, options = {}) { + this.setVolume(options.volume || 1); + options.input = input; return this._playTranscodable(input, options); } @@ -272,10 +266,6 @@ class VoiceBroadcast extends VolumeInterface { } } - guaranteeOpusEngine() { - if (!this.opusEncoder) throw new Error('Couldn\'t find an Opus engine.'); - } - _startPlaying() { if (this.tickInterval) clearInterval(this.tickInterval); // Old code? diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js index 36727e7e8..456e8d09f 100644 --- a/src/client/voice/VoiceConnection.js +++ b/src/client/voice/VoiceConnection.js @@ -8,12 +8,13 @@ const EventEmitter = require('events').EventEmitter; const Prism = require('prism-media'); /** - * Represents a connection to a voice channel in Discord. + * Represents a connection to a guild's voice server. * ```js * // Obtained using: - * voiceChannel.join().then(connection => { + * voiceChannel.join() + * .then(connection => { * - * }); + * }); * ``` * @extends {EventEmitter} */ @@ -132,6 +133,7 @@ class VoiceConnection extends EventEmitter { */ setSpeaking(value) { if (this.speaking === value) return; + if (this.status !== Constants.VoiceStatus.CONNECTED) return; this.speaking = value; this.sockets.ws.sendPacket({ op: Constants.VoiceOPCodes.SPEAKING, @@ -163,7 +165,7 @@ class VoiceConnection extends EventEmitter { } /** - * Set the token and endpoint required to connect to the the voice servers. + * Set the token and endpoint required to connect to the voice servers. * @param {string} token The voice token * @param {string} endpoint The voice endpoint * @returns {void} @@ -245,7 +247,6 @@ class VoiceConnection extends EventEmitter { */ authenticateFailed(reason) { clearTimeout(this.connectTimeout); - this.status = Constants.VoiceStatus.DISCONNECTED; if (this.status === Constants.VoiceStatus.AUTHENTICATING) { /** * Emitted when we fail to initiate a voice connection. @@ -256,6 +257,7 @@ class VoiceConnection extends EventEmitter { } else { this.emit('error', new Error(reason)); } + this.status = Constants.VoiceStatus.DISCONNECTED; } /** @@ -430,6 +432,8 @@ class VoiceConnection extends EventEmitter { * @property {number} [seek=0] The time to seek to * @property {number} [volume=1] The volume to play at * @property {number} [passes=1] How many times to send the voice packet to reduce packet loss + * @property {number|string} [bitrate=48000] The bitrate (quality) of the audio. + * If set to 'auto', the voice channel's bitrate will be used */ /** @@ -440,10 +444,10 @@ class VoiceConnection extends EventEmitter { * @example * // Play files natively * voiceChannel.join() - * .then(connection => { - * const dispatcher = connection.playFile('C:/Users/Discord/Desktop/music.mp3'); - * }) - * .catch(console.error); + * .then(connection => { + * const dispatcher = connection.playFile('C:/Users/Discord/Desktop/music.mp3'); + * }) + * .catch(console.error); */ playFile(file, options) { return this.player.playUnknownStream(`file:${file}`, options); @@ -469,18 +473,18 @@ class VoiceConnection extends EventEmitter { * const ytdl = require('ytdl-core'); * const streamOptions = { seek: 0, volume: 1 }; * voiceChannel.join() - * .then(connection => { - * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', { filter : 'audioonly' }); - * const dispatcher = connection.playStream(stream, streamOptions); - * }) - * .catch(console.error); + * .then(connection => { + * const stream = ytdl('https://www.youtube.com/watch?v=XAWgeLF9EVQ', { filter : 'audioonly' }); + * const dispatcher = connection.playStream(stream, streamOptions); + * }) + * .catch(console.error); */ playStream(stream, options) { return this.player.playUnknownStream(stream, options); } /** - * Plays a stream of 16-bit signed stereo PCM at 48KHz. + * Plays a stream of 16-bit signed stereo PCM. * @param {ReadableStream} stream The audio stream to play * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} @@ -490,7 +494,7 @@ class VoiceConnection extends EventEmitter { } /** - * Plays an Opus encoded stream at 48KHz. + * Plays an Opus encoded stream. * Note that inline volume is not compatible with this method. * @param {ReadableStream} stream The Opus audio stream to play * @param {StreamOptions} [options] Options for playing the stream @@ -503,6 +507,7 @@ class VoiceConnection extends EventEmitter { /** * Plays a voice broadcast. * @param {VoiceBroadcast} broadcast The broadcast to play + * @param {StreamOptions} [options] Options for playing the stream * @returns {StreamDispatcher} * @example * // Play a broadcast @@ -511,12 +516,13 @@ class VoiceConnection extends EventEmitter { * .playFile('./test.mp3'); * const dispatcher = voiceConnection.playBroadcast(broadcast); */ - playBroadcast(broadcast) { - return this.player.playBroadcast(broadcast); + playBroadcast(broadcast, options) { + return this.player.playBroadcast(broadcast, options); } /** - * Creates a VoiceReceiver so you can start listening to voice data. It's recommended to only create one of these. + * Creates a VoiceReceiver so you can start listening to voice data. + * It's recommended to only create one of these. * @returns {VoiceReceiver} */ createReceiver() { diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js index 5e3735627..beef7b9f4 100644 --- a/src/client/voice/dispatcher/StreamDispatcher.js +++ b/src/client/voice/dispatcher/StreamDispatcher.js @@ -1,5 +1,6 @@ const VolumeInterface = require('../util/VolumeInterface'); const VoiceBroadcast = require('../VoiceBroadcast'); +const Constants = require('../../../util/Constants'); const secretbox = require('../util/Secretbox'); @@ -88,12 +89,12 @@ class StreamDispatcher extends VolumeInterface { } /** - * Stops sending voice packets to the voice connection (stream may still progress however) + * Stops sending voice packets to the voice connection (stream may still progress however). */ pause() { this.setPaused(true); } /** - * Resumes sending voice packets to the voice connection (may be further on in the stream than when paused) + * Resumes sending voice packets to the voice connection (may be further on in the stream than when paused). */ resume() { this.setPaused(false); } @@ -108,6 +109,7 @@ class StreamDispatcher extends VolumeInterface { setSpeaking(value) { if (this.speaking === value) return; + if (this.player.voiceConnection.status !== Constants.VoiceStatus.CONNECTED) return; this.speaking = value; /** * Emitted when the dispatcher starts/stops speaking. @@ -117,6 +119,16 @@ class StreamDispatcher extends VolumeInterface { this.emit('speaking', value); } + + /** + * Set the bitrate of the current Opus encoder. + * @param {number} bitrate New bitrate, in kbps + * If set to 'auto', the voice channel's bitrate will be used + */ + setBitrate(bitrate) { + this.player.setBitrate(bitrate); + } + sendBuffer(buffer, sequence, timestamp, opusPacket) { opusPacket = opusPacket || this.player.opusEncoder.encode(buffer); const packet = this.createPacket(sequence, timestamp, opusPacket); @@ -128,7 +140,7 @@ class StreamDispatcher extends VolumeInterface { /** * Emitted whenever the dispatcher has debug information. * @event StreamDispatcher#debug - * @param {string} info the debug info + * @param {string} info The debug info */ this.setSpeaking(true); while (repeats--) { @@ -282,7 +294,7 @@ class StreamDispatcher extends VolumeInterface { this.emit(type, reason); /** * Emitted once the dispatcher ends. - * @param {string} [reason] the reason the dispatcher ended + * @param {string} [reason] The reason the dispatcher ended * @event StreamDispatcher#end */ if (type !== 'end') this.emit('end', `destroyed due to ${type} - ${reason}`); diff --git a/src/client/voice/opus/BaseOpusEngine.js b/src/client/voice/opus/BaseOpusEngine.js index b63b695f6..a51044905 100644 --- a/src/client/voice/opus/BaseOpusEngine.js +++ b/src/client/voice/opus/BaseOpusEngine.js @@ -5,20 +5,37 @@ class BaseOpus { /** * @param {Object} [options] The options to apply to the Opus engine - * @param {boolean} [options.fec] Whether to enable forward error correction (defaults to false) - * @param {number} [options.plp] The expected packet loss percentage (0-1 inclusive, defaults to 0) + * @param {number} [options.bitrate=48] The desired bitrate (kbps) + * @param {boolean} [options.fec=false] Whether to enable forward error correction + * @param {number} [options.plp=0] The expected packet loss percentage */ - constructor(options = {}) { + constructor({ bitrate = 48, fec = false, plp = 0 } = {}) { this.ctl = { + BITRATE: 4002, FEC: 4012, PLP: 4014, }; - this.options = options; + this.samplingRate = 48000; + this.channels = 2; + + /** + * The desired bitrate (kbps) + * @type {number} + */ + this.bitrate = bitrate; + + /** + * Miscellaneous Opus options + * @type {Object} + */ + this.options = { fec, plp }; } init() { try { + this.setBitrate(this.bitrate); + // Set FEC (forward error correction) if (this.options.fec) this.setFEC(this.options.fec); diff --git a/src/client/voice/opus/NodeOpusEngine.js b/src/client/voice/opus/NodeOpusEngine.js index 72f2a818b..02e880637 100644 --- a/src/client/voice/opus/NodeOpusEngine.js +++ b/src/client/voice/opus/NodeOpusEngine.js @@ -10,10 +10,14 @@ class NodeOpusEngine extends OpusEngine { } catch (err) { throw err; } - this.encoder = new opus.OpusEncoder(48000, 2); + this.encoder = new opus.OpusEncoder(this.samplingRate, this.channels); super.init(); } + setBitrate(bitrate) { + this.encoder.applyEncoderCTL(this.ctl.BITRATE, Math.min(128, Math.max(16, bitrate)) * 1000); + } + setFEC(enabled) { this.encoder.applyEncoderCTL(this.ctl.FEC, enabled ? 1 : 0); } diff --git a/src/client/voice/opus/OpusEngineList.js b/src/client/voice/opus/OpusEngineList.js index 447f0afdb..01b6d289f 100644 --- a/src/client/voice/opus/OpusEngineList.js +++ b/src/client/voice/opus/OpusEngineList.js @@ -3,13 +3,14 @@ const list = [ require('./OpusScriptEngine'), ]; -let opusEngineFound; - function fetch(Encoder, engineOptions) { try { return new Encoder(engineOptions); } catch (err) { - return null; + if (err.message.includes('Cannot find module')) return null; + + // The Opus engine exists, but another error occurred. + throw err; } } @@ -22,10 +23,6 @@ exports.fetch = engineOptions => { const fetched = fetch(encoder, engineOptions); if (fetched) return fetched; } - return null; -}; -exports.guaranteeOpusEngine = () => { - if (typeof opusEngineFound === 'undefined') opusEngineFound = Boolean(exports.fetch()); - if (!opusEngineFound) throw new Error('Couldn\'t find an Opus engine.'); + throw new Error('OPUS_ENGINE_MISSING'); }; diff --git a/src/client/voice/opus/OpusScriptEngine.js b/src/client/voice/opus/OpusScriptEngine.js index 81ca6206b..a5e046d40 100644 --- a/src/client/voice/opus/OpusScriptEngine.js +++ b/src/client/voice/opus/OpusScriptEngine.js @@ -10,10 +10,14 @@ class OpusScriptEngine extends OpusEngine { } catch (err) { throw err; } - this.encoder = new OpusScript(48000, 2); + this.encoder = new OpusScript(this.samplingRate, this.channels); super.init(); } + setBitrate(bitrate) { + this.encoder.encoderCTL(this.ctl.BITRATE, Math.min(128, Math.max(16, bitrate)) * 1000); + } + setFEC(enabled) { this.encoder.encoderCTL(this.ctl.FEC, enabled ? 1 : 0); } diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js index afbc9a671..32bb070d1 100644 --- a/src/client/voice/player/AudioPlayer.js +++ b/src/client/voice/player/AudioPlayer.js @@ -30,11 +30,6 @@ class AudioPlayer extends EventEmitter { * @type {Prism} */ this.prism = new Prism(); - /** - * The opus encoder that the player uses - * @type {NodeOpusEngine|OpusScriptEngine} - */ - this.opusEncoder = OpusEncoders.fetch(); this.streams = new Collection(); this.currentStream = {}; this.streamingData = { @@ -67,6 +62,7 @@ class AudioPlayer extends EventEmitter { destroy() { if (this.opusEncoder) this.opusEncoder.destroy(); + this.opusEncoder = null; } destroyCurrentStream() { @@ -83,13 +79,25 @@ class AudioPlayer extends EventEmitter { this.currentStream = {}; } - playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) { - OpusEncoders.guaranteeOpusEngine(); - const options = { seek, volume, passes }; + /** + * Set the bitrate of the current Opus encoder. + * @param {number} value New bitrate, in kbps + * If set to 'auto', the voice channel's bitrate will be used + */ + setBitrate(value) { + if (!value) return; + if (!this.opusEncoder) return; + const bitrate = value === 'auto' ? this.voiceConnection.channel.bitrate : value; + this.opusEncoder.setBitrate(bitrate); + } + + playUnknownStream(stream, options = {}) { + this.destroy(); + this.opusEncoder = OpusEncoders.fetch(options); const transcoder = this.prism.transcode({ type: 'ffmpeg', media: stream, - ffmpegArguments: ffmpegArguments.concat(['-ss', String(seek)]), + ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek || 0)]), }); this.destroyCurrentStream(); this.currentStream = { @@ -105,9 +113,10 @@ class AudioPlayer extends EventEmitter { return this.playPCMStream(transcoder.output, options, true); } - playPCMStream(stream, { seek = 0, volume = 1, passes = 1 } = {}, fromUnknown = false) { - OpusEncoders.guaranteeOpusEngine(); - const options = { seek, volume, passes }; + playPCMStream(stream, options = {}, fromUnknown = false) { + this.destroy(); + this.opusEncoder = OpusEncoders.fetch(options); + this.setBitrate(options.bitrate); const dispatcher = this.createDispatcher(stream, options); if (fromUnknown) { this.currentStream.dispatcher = dispatcher; @@ -122,8 +131,8 @@ class AudioPlayer extends EventEmitter { return dispatcher; } - playOpusStream(stream, { seek = 0, passes = 1 } = {}) { - const options = { seek, passes, opus: true }; + playOpusStream(stream, options = {}) { + options.opus = true; this.destroyCurrentStream(); const dispatcher = this.createDispatcher(stream, options); this.currentStream = { @@ -134,8 +143,7 @@ class AudioPlayer extends EventEmitter { return dispatcher; } - playBroadcast(broadcast, { volume = 1, passes = 1 } = {}) { - const options = { volume, passes }; + playBroadcast(broadcast, options) { this.destroyCurrentStream(); const dispatcher = this.createDispatcher(broadcast, options); this.currentStream = { @@ -148,7 +156,9 @@ class AudioPlayer extends EventEmitter { return dispatcher; } - createDispatcher(stream, options) { + createDispatcher(stream, { seek = 0, volume = 1, passes = 1 } = {}) { + const options = { seek, volume, passes }; + const dispatcher = new StreamDispatcher(this, stream, options); dispatcher.on('end', () => this.destroyCurrentStream()); dispatcher.on('error', () => this.destroyCurrentStream()); diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js index 7b1b6c8e0..c568c6aed 100644 --- a/src/client/voice/receiver/VoiceReceiver.js +++ b/src/client/voice/receiver/VoiceReceiver.js @@ -10,9 +10,10 @@ nonce.fill(0); * Receives voice data from a voice connection. * ```js * // Obtained using: - * voiceChannel.join().then(connection => { - * const receiver = connection.createReceiver(); - * }); + * voiceChannel.join() + * .then(connection => { + * const receiver = connection.createReceiver(); + * }); * ``` * @extends {EventEmitter} */ diff --git a/src/client/websocket/WebSocketConnection.js b/src/client/websocket/WebSocketConnection.js index ecbcf141a..c0581a208 100644 --- a/src/client/websocket/WebSocketConnection.js +++ b/src/client/websocket/WebSocketConnection.js @@ -1,4 +1,4 @@ -const browser = require('os').platform() === 'browser'; +const browser = typeof window !== 'undefined'; const EventEmitter = require('events'); const Constants = require('../../util/Constants'); const zlib = require('zlib'); @@ -29,12 +29,12 @@ const WebSocket = (function findWebSocket() { class WebSocketConnection extends EventEmitter { /** * @param {WebSocketManager} manager The WebSocket manager - * @param {string} gateway WebSocket gateway to connect to + * @param {string} gateway The WebSocket gateway to connect to */ constructor(manager, gateway) { super(); /** - * WebSocket Manager of this connection + * The WebSocket Manager of this connection * @type {WebSocketManager} */ this.manager = manager; @@ -115,6 +115,10 @@ class WebSocketConnection extends EventEmitter { this.debug('Tried to mark self as ready, but already ready'); return; } + /** + * Emitted when the client becomes ready to start working. + * @event Client#ready + */ this.status = Constants.Status.READY; this.client.emit(Constants.Events.READY); this.packetManager.handleQueue(); @@ -228,7 +232,7 @@ class WebSocketConnection extends EventEmitter { /** * Creates a connection to a gateway. - * @param {string} gateway Gateway to connect to + * @param {string} gateway The gateway to connect to * @param {number} [after=0] How long to wait before connecting * @param {boolean} [force=false] Whether or not to force a new connection even if one already exists * @returns {boolean} @@ -280,12 +284,13 @@ class WebSocketConnection extends EventEmitter { * @returns {boolean} */ onMessage(event) { + let data; try { - event.data = this.unpack(event.data); + data = this.unpack(event.data); } catch (err) { this.emit('debug', err); } - return this.onPacket(event.data); + return this.onPacket(data); } /** @@ -351,11 +356,19 @@ class WebSocketConnection extends EventEmitter { /** * Called whenever an error occurs with the WebSocket. - * @param {Error} error Error that occurred + * @param {Error} error The error that occurred */ onError(error) { + if (error && error.message === 'uWs client connection error') { + this.reconnect(); + return; + } + /** + * Emitted whenever the client's WebSocket encounters a connection error. + * @event Client#error + * @param {Error} error The encountered error + */ this.client.emit(Constants.Events.ERROR, error); - if (error.message === 'uWs client connection error') this.reconnect(); } /** @@ -431,7 +444,7 @@ class WebSocketConnection extends EventEmitter { * @returns {void} */ identify(after) { - if (after) return this.client.setTimeout(this.identify.apply(this), after); + if (after) return this.client.setTimeout(this.identify.bind(this), after); return this.sessionID ? this.identifyResume() : this.identifyNew(); } diff --git a/src/client/websocket/WebSocketManager.js b/src/client/websocket/WebSocketManager.js index aabea1d9b..9ef073f24 100644 --- a/src/client/websocket/WebSocketManager.js +++ b/src/client/websocket/WebSocketManager.js @@ -3,7 +3,7 @@ const Constants = require('../../util/Constants'); const WebSocketConnection = require('./WebSocketConnection'); /** - * WebSocket Manager of the client + * WebSocket Manager of the client. * @private */ class WebSocketManager extends EventEmitter { @@ -23,7 +23,7 @@ class WebSocketManager extends EventEmitter { } /** - * Sends a heartbeat on the available connection + * Sends a heartbeat on the available connection. * @returns {void} */ heartbeat() { @@ -67,7 +67,7 @@ class WebSocketManager extends EventEmitter { /** * Connects the client to a gateway. - * @param {string} gateway Gateway to connect to + * @param {string} gateway The gateway to connect to * @returns {boolean} */ connect(gateway) { diff --git a/src/client/websocket/packets/WebSocketPacketManager.js b/src/client/websocket/packets/WebSocketPacketManager.js index 079ef5d8c..efc42df4a 100644 --- a/src/client/websocket/packets/WebSocketPacketManager.js +++ b/src/client/websocket/packets/WebSocketPacketManager.js @@ -39,6 +39,7 @@ class WebSocketPacketManager { this.register(Constants.WSEvents.USER_UPDATE, require('./handlers/UserUpdate')); this.register(Constants.WSEvents.USER_NOTE_UPDATE, require('./handlers/UserNoteUpdate')); this.register(Constants.WSEvents.USER_SETTINGS_UPDATE, require('./handlers/UserSettingsUpdate')); + this.register(Constants.WSEvents.USER_GUILD_SETTINGS_UPDATE, require('./handlers/UserGuildSettingsUpdate')); this.register(Constants.WSEvents.VOICE_STATE_UPDATE, require('./handlers/VoiceStateUpdate')); this.register(Constants.WSEvents.TYPING_START, require('./handlers/TypingStart')); this.register(Constants.WSEvents.MESSAGE_CREATE, require('./handlers/MessageCreate')); diff --git a/src/client/websocket/packets/handlers/GuildMembersChunk.js b/src/client/websocket/packets/handlers/GuildMembersChunk.js index 835bcb573..445864438 100644 --- a/src/client/websocket/packets/handlers/GuildMembersChunk.js +++ b/src/client/websocket/packets/handlers/GuildMembersChunk.js @@ -26,7 +26,7 @@ class GuildMembersChunkHandler extends AbstractHandler { /** * Emitted whenever a chunk of guild members is received (all members come from the same guild). * @event Client#guildMembersChunk - * @param {Collection} members The members in the chunk + * @param {GuildMember[]} members The members in the chunk * @param {Guild} guild The guild related to the member chunk */ diff --git a/src/client/websocket/packets/handlers/Ready.js b/src/client/websocket/packets/handlers/Ready.js index d7ee8ed11..8c2492abf 100644 --- a/src/client/websocket/packets/handlers/Ready.js +++ b/src/client/websocket/packets/handlers/Ready.js @@ -10,6 +10,7 @@ class ReadyHandler extends AbstractHandler { client.ws.heartbeat(); data.user.user_settings = data.user_settings; + data.user.user_guild_settings = data.user_guild_settings; const clientUser = new ClientUser(client, data.user); client.user = clientUser; diff --git a/src/client/websocket/packets/handlers/Resumed.js b/src/client/websocket/packets/handlers/Resumed.js index 7b4387092..0c796053b 100644 --- a/src/client/websocket/packets/handlers/Resumed.js +++ b/src/client/websocket/packets/handlers/Resumed.js @@ -14,7 +14,7 @@ class ResumedHandler extends AbstractHandler { const replayed = ws.sequence - ws.closeSequence; ws.debug(`RESUMED ${ws._trace.join(' -> ')} | replayed ${replayed} events.`); - client.emit('resume', replayed); + client.emit(Constants.Events.RESUME, replayed); ws.heartbeat(); } } diff --git a/src/client/websocket/packets/handlers/UserGuildSettingsUpdate.js b/src/client/websocket/packets/handlers/UserGuildSettingsUpdate.js new file mode 100644 index 000000000..1470a3c84 --- /dev/null +++ b/src/client/websocket/packets/handlers/UserGuildSettingsUpdate.js @@ -0,0 +1,18 @@ +const AbstractHandler = require('./AbstractHandler'); +const Constants = require('../../../../util/Constants'); + +class UserGuildSettingsUpdateHandler extends AbstractHandler { + handle(packet) { + const client = this.packetManager.client; + client.user.guildSettings.get(packet.d.guild_id).patch(packet.d); + client.emit(Constants.Events.USER_GUILD_SETTINGS_UPDATE, client.user.guildSettings.get(packet.d.guild_id)); + } +} + +/** + * Emitted whenever the client user's settings update. + * @event Client#clientUserGuildSettingsUpdate + * @param {ClientUserGuildSettings} clientUserGuildSettings The new client user guild settings + */ + +module.exports = UserGuildSettingsUpdateHandler; diff --git a/src/index.js b/src/index.js index bbf84c8a1..d2b38d14f 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ module.exports = { // Utilities Collection: require('./util/Collection'), Constants: require('./util/Constants'), + DiscordAPIError: require('./client/rest/DiscordAPIError'), EvaluatedPermissions: require('./util/Permissions'), Permissions: require('./util/Permissions'), Snowflake: require('./util/Snowflake'), @@ -25,6 +26,7 @@ module.exports = { splitMessage: Util.splitMessage, // Structures + Attachment: require('./structures/Attachment'), Channel: require('./structures/Channel'), ClientUser: require('./structures/ClientUser'), ClientUserSettings: require('./structures/ClientUserSettings'), @@ -59,5 +61,3 @@ module.exports = { VoiceChannel: require('./structures/VoiceChannel'), Webhook: require('./structures/Webhook'), }; - -if (require('os').platform() === 'browser') window.Discord = module.exports; // eslint-disable-line no-undef diff --git a/src/sharding/Shard.js b/src/sharding/Shard.js index a73447b85..a1069ee9f 100644 --- a/src/sharding/Shard.js +++ b/src/sharding/Shard.js @@ -69,9 +69,11 @@ class Shard { * @param {string} prop Name of the client property to get, using periods for nesting * @returns {Promise<*>} * @example - * shard.fetchClientValue('guilds.size').then(count => { - * console.log(`${count} guilds in shard ${shard.id}`); - * }).catch(console.error); + * shard.fetchClientValue('guilds.size') + * .then(count => { + * console.log(`${count} guilds in shard ${shard.id}`); + * }) + * .catch(console.error); */ fetchClientValue(prop) { if (this._fetches.has(prop)) return this._fetches.get(prop); diff --git a/src/sharding/ShardClientUtil.js b/src/sharding/ShardClientUtil.js index b41deb5d1..0d7d4412b 100644 --- a/src/sharding/ShardClientUtil.js +++ b/src/sharding/ShardClientUtil.js @@ -49,9 +49,11 @@ class ShardClientUtil { * @param {string} prop Name of the client property to get, using periods for nesting * @returns {Promise} * @example - * client.shard.fetchClientValues('guilds.size').then(results => { - * console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`); - * }).catch(console.error); + * client.shard.fetchClientValues('guilds.size') + * .then(results => { + * console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`); + * }) + * .catch(console.error); */ fetchClientValues(prop) { return new Promise((resolve, reject) => { diff --git a/src/sharding/ShardingManager.js b/src/sharding/ShardingManager.js index e105f3e3f..aa40547bd 100644 --- a/src/sharding/ShardingManager.js +++ b/src/sharding/ShardingManager.js @@ -176,9 +176,11 @@ class ShardingManager extends EventEmitter { * @param {string} prop Name of the client property to get, using periods for nesting * @returns {Promise} * @example - * manager.fetchClientValues('guilds.size').then(results => { - * console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`); - * }).catch(console.error); + * manager.fetchClientValues('guilds.size') + * .then(results => { + * console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`); + * }) + * .catch(console.error); */ fetchClientValues(prop) { if (this.shards.size === 0) return Promise.reject(new Error('No shards have been spawned.')); diff --git a/src/structures/Attachment.js b/src/structures/Attachment.js new file mode 100644 index 000000000..216b61c9d --- /dev/null +++ b/src/structures/Attachment.js @@ -0,0 +1,75 @@ +/** + * Represents an attachment in a message. + * @param {BufferResolvable|Stream} file The file + * @param {string} [name] The name of the file, if any + */ +class Attachment { + constructor(file, name) { + this.file = null; + if (name) this.setAttachment(file, name); + else this._attach(file); + } + + /** + * The name of the file + * @type {?string} + * @readonly + */ + get name() { + return this.file.name; + } + + /** + * The file + * @type {?BufferResolvable|Stream} + * @readonly + */ + get attachment() { + return this.file.attachment; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} file The file + * @param {string} name The name of the file + * @returns {Attachment} This attachment + */ + setAttachment(file, name) { + this.file = { attachment: file, name }; + return this; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} attachment The file + * @returns {Attachment} This attachment + */ + setFile(attachment) { + this.file = { attachment }; + return this; + } + + /** + * Set the name of this attachment. + * @param {string} name The name of the image + * @returns {Attachment} This attachment + */ + setName(name) { + this.file.name = name; + return this; + } + + /** + * Set the file of this attachment. + * @param {BufferResolvable|Stream} file The file + * @param {string} name The name of the file + * @returns {void} + * @private + */ + _attach(file, name) { + if (typeof file === 'string') this.file = file; + else this.setAttachment(file, name); + } +} + +module.exports = Attachment; diff --git a/src/structures/Channel.js b/src/structures/Channel.js index 3d492e451..5cf03a608 100644 --- a/src/structures/Channel.js +++ b/src/structures/Channel.js @@ -58,8 +58,8 @@ class Channel { * @example * // Delete the channel * channel.delete() - * .then() // Success - * .catch(console.error); // Log error + * .then() // Success + * .catch(console.error); // Log error */ delete() { return this.client.rest.methods.deleteChannel(this); diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js index 9fe1a3d96..ef3398bd3 100644 --- a/src/structures/ClientUser.js +++ b/src/structures/ClientUser.js @@ -1,6 +1,7 @@ const User = require('./User'); const Collection = require('../util/Collection'); const ClientUserSettings = require('./ClientUserSettings'); +const ClientUserGuildSettings = require('./ClientUserGuildSettings'); const Constants = require('../util/Constants'); /** @@ -72,7 +73,19 @@ class ClientUser extends User { * This is only filled when using a user account. * @type {?ClientUserSettings} */ - if (data.user_settings) this.settings = new ClientUserSettings(this, data.user_settings); + this.settings = data.user_settings ? new ClientUserSettings(this, data.user_settings) : null; + + /** + * All of the user's guild settings + * This is only filled when using a user account + * @type {Collection} + */ + this.guildSettings = new Collection(); + if (data.user_guild_settings) { + for (const settings of data.user_guild_settings) { + this.guildSettings.set(settings.guild_id, new ClientUserGuildSettings(settings, this.client)); + } + } } edit(data) { @@ -89,8 +102,8 @@ class ClientUser extends User { * @example * // Set username * client.user.setUsername('discordjs') - * .then(user => console.log(`My new username is ${user.username}`)) - * .catch(console.error); + * .then(user => console.log(`My new username is ${user.username}`)) + * .catch(console.error); */ setUsername(username, password) { return this.client.rest.methods.updateCurrentUser({ username }, password); @@ -105,8 +118,8 @@ class ClientUser extends User { * @example * // Set email * client.user.setEmail('bob@gmail.com', 'some amazing password 123') - * .then(user => console.log(`My new email is ${user.email}`)) - * .catch(console.error); + * .then(user => console.log(`My new email is ${user.email}`)) + * .catch(console.error); */ setEmail(email, password) { return this.client.rest.methods.updateCurrentUser({ email }, password); @@ -121,8 +134,8 @@ class ClientUser extends User { * @example * // Set password * client.user.setPassword('some new amazing password 456', 'some amazing password 123') - * .then(user => console.log('New password set!')) - * .catch(console.error); + * .then(user => console.log('New password set!')) + * .catch(console.error); */ setPassword(newPassword, oldPassword) { return this.client.rest.methods.updateCurrentUser({ password: newPassword }, oldPassword); @@ -135,17 +148,13 @@ class ClientUser extends User { * @example * // Set avatar * client.user.setAvatar('./avatar.png') - * .then(user => console.log(`New avatar set!`)) - * .catch(console.error); + * .then(user => console.log(`New avatar set!`)) + * .catch(console.error); */ setAvatar(avatar) { - if (typeof avatar === 'string' && avatar.startsWith('data:')) { - return this.client.rest.methods.updateCurrentUser({ avatar }); - } else { - return this.client.resolver.resolveBuffer(avatar).then(data => - this.client.rest.methods.updateCurrentUser({ avatar: data }) - ); - } + return this.client.resolver.resolveImage(avatar).then(data => + this.client.rest.methods.updateCurrentUser({ avatar: data }) + ); } /** @@ -190,7 +199,7 @@ class ClientUser extends User { if (data.game) { game = data.game; - if (game.url) game.type = 1; + game.type = game.url ? 1 : 0; } else if (typeof data.game !== 'undefined') { game = null; } @@ -215,10 +224,10 @@ class ClientUser extends User { /** * A user's status. Must be one of: - * - `online` - * - `idle` - * - `invisible` - * - `dnd` (do not disturb) + * * `online` + * * `idle` + * * `invisible` + * * `dnd` (do not disturb) * @typedef {string} PresenceStatus */ @@ -265,7 +274,7 @@ class ClientUser extends User { * @param {Guild|Snowflake} [options.guild] Limit the search to a specific guild * @returns {Promise} */ - fetchMentions(options = { limit: 25, roles: true, everyone: true, guild: null }) { + fetchMentions(options = {}) { return this.client.rest.methods.fetchMentions(options); } @@ -295,16 +304,15 @@ class ClientUser extends User { * Creates a guild. * This is only available when using a user account. * @param {string} name The name of the guild - * @param {string} region The region for the server + * @param {string} [region] The region for the server * @param {BufferResolvable|Base64Resolvable} [icon=null] The icon for the guild * @returns {Promise} The guild that was created */ createGuild(name, region, icon = null) { - if (!icon) return this.client.rest.methods.createGuild({ name, icon, region }); if (typeof icon === 'string' && icon.startsWith('data:')) { return this.client.rest.methods.createGuild({ name, icon, region }); } else { - return this.client.resolver.resolveBuffer(icon).then(data => + return this.client.resolver.resolveImage(icon).then(data => this.client.rest.methods.createGuild({ name, icon: data, region }) ); } diff --git a/src/structures/ClientUserChannelOverride.js b/src/structures/ClientUserChannelOverride.js new file mode 100644 index 000000000..93efa45ff --- /dev/null +++ b/src/structures/ClientUserChannelOverride.js @@ -0,0 +1,30 @@ +const Constants = require('../util/Constants'); + +/** + * A wrapper around the ClientUser's channel overrides. + */ +class ClientUserChannelOverride { + constructor(data) { + this.patch(data); + } + + /** + * Patch the data contained in this class with new partial data. + * @param {Object} data Data to patch this with + * @returns {void} + * @private + */ + patch(data) { + for (const key of Object.keys(Constants.UserChannelOverrideMap)) { + const value = Constants.UserChannelOverrideMap[key]; + if (!data.hasOwnProperty(key)) continue; + if (typeof value === 'function') { + this[value.name] = value(data[key]); + } else { + this[value] = data[key]; + } + } + } +} + +module.exports = ClientUserChannelOverride; diff --git a/src/structures/ClientUserGuildSettings.js b/src/structures/ClientUserGuildSettings.js new file mode 100644 index 000000000..5a28747ec --- /dev/null +++ b/src/structures/ClientUserGuildSettings.js @@ -0,0 +1,60 @@ +const Constants = require('../util/Constants'); +const Collection = require('../util/Collection'); +const ClientUserChannelOverride = require('./ClientUserChannelOverride'); + +/** + * A wrapper around the ClientUser's guild settings. + */ +class ClientUserGuildSettings { + constructor(data, client) { + /** + * The client that created the instance of the ClientUserGuildSettings + * @name ClientUserGuildSettings#client + * @type {Client} + * @readonly + */ + Object.defineProperty(this, 'client', { value: client }); + /** + * The ID of the guild this settings are for + * @type {Snowflake} + */ + this.guildID = data.guild_id; + this.channelOverrides = new Collection(); + this.patch(data); + } + + /** + * Patch the data contained in this class with new partial data. + * @param {Object} data Data to patch this with + * @returns {void} + * @private + */ + patch(data) { + for (const key of Object.keys(Constants.UserGuildSettingsMap)) { + const value = Constants.UserGuildSettingsMap[key]; + if (!data.hasOwnProperty(key)) continue; + if (key === 'channel_overrides') { + for (const channel of data[key]) { + this.channelOverrides.set(channel.channel_id, + new ClientUserChannelOverride(channel)); + } + } else if (typeof value === 'function') { + this[value.name] = value(data[key]); + } else { + this[value] = data[key]; + } + } + } + + /** + * Update a specific property of the guild settings. + * @param {string} name Name of property + * @param {value} value Value to patch + * @returns {Promise} + */ + update(name, value) { + return this.client.rest.methods.patchClientUserGuildSettings(this.guildID, { [name]: value }); + } +} + +module.exports = ClientUserGuildSettings; diff --git a/src/structures/ClientUserSettings.js b/src/structures/ClientUserSettings.js index 798f348c5..6b18c0394 100644 --- a/src/structures/ClientUserSettings.js +++ b/src/structures/ClientUserSettings.js @@ -13,6 +13,8 @@ class ClientUserSettings { /** * Patch the data contained in this class with new partial data. * @param {Object} data Data to patch this with + * @returns {void} + * @private */ patch(data) { for (const key of Object.keys(Constants.UserSettingsMap)) { @@ -29,7 +31,7 @@ class ClientUserSettings { /** * Update a specific property of of user settings. * @param {string} name Name of property - * @param {value} value Value to patch + * @param {*} value Value to patch * @returns {Promise} */ update(name, value) { @@ -37,6 +39,7 @@ class ClientUserSettings { } /** + * Sets the position at which this guild will appear in the Discord client. * @param {Guild} guild The guild to move * @param {number} position Absolute or relative position * @param {boolean} [relative=false] Whether to position relatively or absolutely diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 9c74aad4e..063c07286 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -53,6 +53,7 @@ class DMChannel extends Channel { get typing() {} get typingCount() {} createCollector() {} + createMessageCollector() {} awaitMessages() {} // Doesn't work on DM channels; bulkDelete() {} acknowledge() {} diff --git a/src/structures/Emoji.js b/src/structures/Emoji.js index 452dd6654..246c95740 100644 --- a/src/structures/Emoji.js +++ b/src/structures/Emoji.js @@ -112,24 +112,26 @@ class Emoji { /** * Edits the emoji. * @param {EmojiEditData} data The new data for the emoji + * @param {string} [reason] Reason for editing this emoji * @returns {Promise} * @example - * // Edit a emoji + * // Edit an emoji * emoji.edit({name: 'newemoji'}) - * .then(e => console.log(`Edited emoji ${e}`)) - * .catch(console.error); + * .then(e => console.log(`Edited emoji ${e}`)) + * .catch(console.error); */ - edit(data) { - return this.client.rest.methods.updateEmoji(this, data); + edit(data, reason) { + return this.client.rest.methods.updateEmoji(this, data, reason); } /** * Set the name of the emoji. * @param {string} name The new name for the emoji + * @param {string} [reason] The reason for changing the emoji's name * @returns {Promise} */ - setName(name) { - return this.edit({ name }); + setName(name, reason) { + return this.edit({ name }, reason); } /** diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js index d7ee76b50..efa20b2c2 100644 --- a/src/structures/GroupDMChannel.js +++ b/src/structures/GroupDMChannel.js @@ -1,6 +1,7 @@ const Channel = require('./Channel'); const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Collection = require('../util/Collection'); +const Constants = require('../util/Constants'); /* { type: 3, @@ -47,8 +48,8 @@ class GroupDMChannel extends Channel { this.name = data.name; /** - * A hash of the Group DM icon. - * @type {string} + * A hash of this Group DM icon + * @type {?string} */ this.icon = data.icon; @@ -70,11 +71,13 @@ class GroupDMChannel extends Channel { */ this.applicationID = data.application_id; - /** - * Nicknames for group members - * @type {?Collection} - */ - if (data.nicks) this.nicks = new Collection(data.nicks.map(n => [n.id, n.nick])); + if (data.nicks) { + /** + * Nicknames for group members + * @type {?Collection} + */ + this.nicks = new Collection(data.nicks.map(n => [n.id, n.nick])); + } if (!this.recipients) { /** @@ -103,6 +106,23 @@ class GroupDMChannel extends Channel { return this.client.users.get(this.ownerID); } + /** + * The URL to this guild's icon + * @type {?string} + * @readonly + */ + get iconURL() { + if (!this.icon) return null; + return Constants.Endpoints.Channel(this).Icon(this.client.options.http.cdn, this.icon); + } + + edit(data) { + const _data = {}; + if (data.name) _data.name = data.name; + if (typeof data.icon !== 'undefined') _data.icon = data.icon; + return this.client.rest.methods.updateGroupDMChannel(this, _data); + } + /** * Whether this channel equals another channel. It compares all properties, so for most operations * it is advisable to just compare `channel.id === channel2.id` as it is much faster and is often @@ -128,6 +148,7 @@ class GroupDMChannel extends Channel { * Add a user to the DM * @param {UserResolvable|string} accessTokenOrID Access token or user resolvable * @param {string} [nick] Permanent nickname to give the user (only available if a bot is creating the DM) + * @returns {Promise} */ addUser(accessTokenOrID, nick) { @@ -138,6 +159,39 @@ class GroupDMChannel extends Channel { }); } + /** + * Set a new GroupDMChannel icon. + * @param {Base64Resolvable|BufferResolvable} icon The new icon of the group dm + * @returns {Promise} + * @example + * // Edit the group dm icon + * channel.setIcon('./icon.png') + * .then(updated => console.log('Updated the channel icon')) + * .catch(console.error); + */ + setIcon(icon) { + return this.client.resolver.resolveImage(icon).then(data => this.edit({ icon: data })); + } + + /** + * Sets a new name for this Group DM. + * @param {string} name New name for this Group DM + * @returns {Promise} + */ + setName(name) { + return this.edit({ name }); + } + + /** + * Removes an user from this Group DM. + * @param {UserResolvable} user User to remove + * @returns {Promise} + */ + removeUser(user) { + const id = this.client.resolver.resolveUserID(user); + return this.client.rest.methods.removeUserFromGroupDM(this, id); + } + /** * When concatenated with a string, this automatically concatenates the channel's name instead of the Channel object. * @returns {string} @@ -169,6 +223,7 @@ class GroupDMChannel extends Channel { get typing() {} get typingCount() {} createCollector() {} + createMessageCollector() {} awaitMessages() {} // Doesn't work on Group DMs; bulkDelete() {} acknowledge() {} diff --git a/src/structures/Guild.js b/src/structures/Guild.js index bbb74624f..fac0141b5 100644 --- a/src/structures/Guild.js +++ b/src/structures/Guild.js @@ -1,3 +1,4 @@ +const util = require('util'); const Long = require('long'); const User = require('./User'); const Role = require('./Role'); @@ -17,7 +18,7 @@ const Snowflake = require('../util/Snowflake'); class Guild { constructor(client, data) { /** - * The client that created the instance of the the guild + * The client that created the instance of the guild * @name Guild#client * @type {Client} * @readonly @@ -62,8 +63,8 @@ class Guild { */ this.id = data.id; } else { - this.available = true; this.setup(data); + if (!data.channels) this.available = false; } } @@ -133,6 +134,12 @@ class Guild { */ this.afkChannelID = data.afk_channel_id; + /** + * The ID of the system channel + * @type {?Snowflake} + */ + this.systemChannelID = data.system_channel_id; + /** * Whether embedded images are enabled on this guild * @type {boolean} @@ -212,7 +219,8 @@ class Guild { if (!this.emojis) { /** - * A collection of emojis that are in this guild. The key is the emoji's ID, the value is the emoji. + * A collection of emojis that are in this guild + * The key is the emoji's ID, the value is the emoji * @type {Collection} */ this.emojis = new Collection(); @@ -262,6 +270,15 @@ class Guild { return Constants.Endpoints.Guild(this).Icon(this.client.options.http.cdn, this.icon); } + /** + * The acronym that shows up in place of a guild icon. + * @type {string} + * @readonly + */ + get nameAcronym() { + return this.name.replace(/\w+/g, name => name[0]).replace(/\s/g, ''); + } + /** * The URL to this guild's splash * @type {?string} @@ -281,6 +298,24 @@ class Guild { return this.members.get(this.ownerID); } + /** + * AFK voice channel for this guild + * @type {?VoiceChannel} + * @readonly + */ + get afkChannel() { + return this.client.channels.get(this.afkChannelID) || null; + } + + /** + * System channel for this guild + * @type {?GuildChannel} + * @readonly + */ + get systemChannel() { + return this.client.channels.get(this.systemChannelID) || null; + } + /** * If the client is connected to any voice channel in this guild, this will be the relevant VoiceConnection * @type {?VoiceConnection} @@ -291,19 +326,11 @@ class Guild { return this.client.voice.connections.get(this.id) || null; } - /** - * The `#general` TextChannel of the guild - * @type {TextChannel} - * @readonly - */ - get defaultChannel() { - return this.channels.get(this.id); - } - /** * The position of this guild * This is only available when using a user account. * @type {?number} + * @readonly */ get position() { if (this.client.user.bot) return null; @@ -311,6 +338,66 @@ class Guild { return this.client.user.settings.guildPositions.indexOf(this.id); } + /** + * Whether the guild is muted + * This is only available when using a user account. + * @type {?boolean} + * @readonly + */ + get muted() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.id).muted; + } catch (err) { + return false; + } + } + + /** + * The type of message that should notify you + * This is only available when using a user account. + * @type {?MessageNotificationType} + * @readonly + */ + get messageNotifications() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.id).messageNotifications; + } catch (err) { + return null; + } + } + + /** + * Whether to receive mobile push notifications + * This is only available when using a user account. + * @type {?boolean} + * @readonly + */ + get mobilePush() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.id).mobilePush; + } catch (err) { + return false; + } + } + + /** + * Whether to suppress everyone messages + * This is only available when using a user account. + * @type {?boolean} + * @readonly + */ + get suppressEveryone() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.id).suppressEveryone; + } catch (err) { + return null; + } + } + /** * The `@everyone` role of the guild * @type {Role} @@ -366,7 +453,8 @@ class Guild { } /** - * Fetch a collection of invites to this guild. Resolves with a collection mapping invites by their codes. + * Fetch a collection of invites to this guild. + * Resolves with a collection mapping invites by their codes. * @returns {Promise>} */ fetchInvites() { @@ -424,7 +512,7 @@ class Guild { /** * Fetch a single guild member from a user. * @param {UserResolvable} user The user to fetch the member for - * @param {boolean} [cache=true] Insert the user into the users cache + * @param {boolean} [cache=true] Insert the member into the members cache * @returns {Promise} */ fetchMember(user, cache = true) { @@ -475,9 +563,7 @@ class Guild { * Performs a search within the entire guild. * This is only available when using a user account. * @param {MessageSearchOptions} [options={}] Options to pass to the search - * @returns {Promise>} - * An array containing arrays of messages. Each inner array is a search context cluster. - * The message which has triggered the result will have the `hit` property set to `true`. + * @returns {Promise} * @example * guild.search({ * content: 'discord.js', @@ -497,7 +583,9 @@ class Guild { * @property {string} [name] The name of the guild * @property {string} [region] The region of the guild * @property {number} [verificationLevel] The verification level of the guild + * @property {number} [explicitContentFilter] The level of the explicit content filter * @property {ChannelResolvable} [afkChannel] The AFK channel of the guild + * @property {ChannelResolvable} [systemChannel] The system channel of the guild * @property {number} [afkTimeout] The AFK timeout of the guild * @property {Base64Resolvable} [icon] The icon of the guild * @property {GuildMemberResolvable} [owner] The owner of the guild @@ -507,23 +595,52 @@ class Guild { /** * Updates the guild with new information - e.g. a new name. * @param {GuildEditData} data The data to update the guild with + * @param {string} [reason] Reason for editing the guild * @returns {Promise} * @example * // Set the guild name and region * guild.edit({ - * name: 'Discord Guild', - * region: 'london', + * name: 'Discord Guild', + * region: 'london', * }) - * .then(updated => console.log(`New guild name ${updated.name} in region ${updated.region}`)) - * .catch(console.error); + * .then(updated => console.log(`New guild name ${updated.name} in region ${updated.region}`)) + * .catch(console.error); */ - edit(data) { - return this.client.rest.methods.updateGuild(this, data); + edit(data, reason) { + const _data = {}; + if (data.name) _data.name = data.name; + if (data.region) _data.region = data.region; + if (typeof data.verificationLevel !== 'undefined') _data.verification_level = Number(data.verificationLevel); + if (typeof data.afkChannel !== 'undefined') { + _data.afk_channel_id = this.client.resolver.resolveChannelID(data.afkChannel); + } + if (typeof data.systemChannel !== 'undefined') { + _data.system_channel_id = this.client.resolver.resolveChannelID(data.systemChannel); + } + if (data.afkTimeout) _data.afk_timeout = Number(data.afkTimeout); + if (typeof data.icon !== 'undefined') _data.icon = data.icon; + if (data.owner) _data.owner_id = this.client.resolver.resolveUser(data.owner).id; + if (typeof data.splash !== 'undefined') _data.splash = data.splash; + if (typeof data.explicitContentFilter !== 'undefined') { + _data.explicit_content_filter = Number(data.explicitContentFilter); + } + return this.client.rest.methods.updateGuild(this, _data, reason); + } + + /** + * Edit the level of the explicit content filter. + * @param {number} explicitContentFilter The new level of the explicit content filter + * @param {string} [reason] Reason for changing the level of the guild's explicit content filter + * @returns {Promise} + */ + setExplicitContentFilter(explicitContentFilter, reason) { + return this.edit({ explicitContentFilter }, reason); } /** * Edit the name of the guild. * @param {string} name The new name of the guild + * @param {string} [reason] Reason for changing the guild's name * @returns {Promise} * @example * // Edit the guild name @@ -531,13 +648,14 @@ class Guild { * .then(updated => console.log(`Updated guild name to ${guild.name}`)) * .catch(console.error); */ - setName(name) { - return this.edit({ name }); + setName(name, reason) { + return this.edit({ name }, reason); } /** * Edit the region of the guild. * @param {string} region The new region of the guild + * @param {string} [reason] Reason for changing the guild's region * @returns {Promise} * @example * // Edit the guild region @@ -545,13 +663,14 @@ class Guild { * .then(updated => console.log(`Updated guild region to ${guild.region}`)) * .catch(console.error); */ - setRegion(region) { - return this.edit({ region }); + setRegion(region, reason) { + return this.edit({ region }, reason); } /** * Edit the verification level of the guild. * @param {number} verificationLevel The new verification level of the guild + * @param {string} [reason] Reason for changing the guild's verification level * @returns {Promise} * @example * // Edit the guild verification level @@ -559,13 +678,14 @@ class Guild { * .then(updated => console.log(`Updated guild verification level to ${guild.verificationLevel}`)) * .catch(console.error); */ - setVerificationLevel(verificationLevel) { - return this.edit({ verificationLevel }); + setVerificationLevel(verificationLevel, reason) { + return this.edit({ verificationLevel }, reason); } /** * Edit the AFK channel of the guild. * @param {ChannelResolvable} afkChannel The new AFK channel + * @param {string} [reason] Reason for changing the guild's AFK channel * @returns {Promise} * @example * // Edit the guild AFK channel @@ -573,13 +693,24 @@ class Guild { * .then(updated => console.log(`Updated guild AFK channel to ${guild.afkChannel}`)) * .catch(console.error); */ - setAFKChannel(afkChannel) { - return this.edit({ afkChannel }); + setAFKChannel(afkChannel, reason) { + return this.edit({ afkChannel }, reason); + } + + /** + * Edit the system channel of the guild. + * @param {ChannelResolvable} systemChannel The new system channel + * @param {string} [reason] Reason for changing the guild's system channel + * @returns {Promise} + */ + setSystemChannel(systemChannel, reason) { + return this.edit({ systemChannel }, reason); } /** * Edit the AFK timeout of the guild. * @param {number} afkTimeout The time in seconds that a user must be idle to be considered AFK + * @param {string} [reason] Reason for changing the guild's AFK timeout * @returns {Promise} * @example * // Edit the guild AFK channel @@ -587,27 +718,29 @@ class Guild { * .then(updated => console.log(`Updated guild AFK timeout to ${guild.afkTimeout}`)) * .catch(console.error); */ - setAFKTimeout(afkTimeout) { - return this.edit({ afkTimeout }); + setAFKTimeout(afkTimeout, reason) { + return this.edit({ afkTimeout }, reason); } /** * Set a new guild icon. - * @param {Base64Resolvable} icon The new icon of the guild + * @param {Base64Resolvable|BufferResolvable} icon The new icon of the guild + * @param {string} [reason] Reason for changing the guild's icon * @returns {Promise} * @example * // Edit the guild icon - * guild.setIcon(fs.readFileSync('./icon.png')) + * guild.setIcon('./icon.png') * .then(updated => console.log('Updated the guild icon')) * .catch(console.error); */ - setIcon(icon) { - return this.edit({ icon }); + setIcon(icon, reason) { + return this.client.resolver.resolveImage(icon).then(data => this.edit({ icon: data, reason })); } /** * Sets a new owner of the guild. * @param {GuildMemberResolvable} owner The new owner of the guild + * @param {string} [reason] Reason for setting the new owner * @returns {Promise} * @example * // Edit the guild owner @@ -615,25 +748,28 @@ class Guild { * .then(updated => console.log(`Updated the guild owner to ${updated.owner.username}`)) * .catch(console.error); */ - setOwner(owner) { - return this.edit({ owner }); + setOwner(owner, reason) { + return this.edit({ owner }, reason); } /** * Set a new guild splash screen. - * @param {Base64Resolvable} splash The new splash screen of the guild + * @param {BufferResolvable|Base64Resolvable} splash The new splash screen of the guild + * @param {string} [reason] Reason for changing the guild's splash screen * @returns {Promise} * @example * // Edit the guild splash - * guild.setIcon(fs.readFileSync('./splash.png')) + * guild.setSplash('./splash.png') * .then(updated => console.log('Updated the guild splash')) * .catch(console.error); */ setSplash(splash) { - return this.edit({ splash }); + return this.client.resolver.resolveImage(splash).then(data => this.edit({ splash: data })); } /** + * Sets the position of the guild in the guild listing. + * This is only available when using a user account. * @param {number} position Absolute or relative position * @param {boolean} [relative=false] Whether to position relatively or absolutely * @returns {Promise} @@ -648,7 +784,7 @@ class Guild { /** * Marks all messages in this guild as read. * This is only available when using a user account. - * @returns {Promise} This guild + * @returns {Promise} */ acknowledge() { return this.client.rest.methods.ackGuild(this); @@ -656,6 +792,7 @@ class Guild { /** * Allow direct messages from guild members. + * This is only available when using a user account. * @param {boolean} allow Whether to allow direct messages * @returns {Promise} */ @@ -678,8 +815,8 @@ class Guild { * @example * // Ban a user by ID (or with a user/guild member object) * guild.ban('some user ID') - * .then(user => console.log(`Banned ${user.username || user.id || user} from ${guild.name}`)) - * .catch(console.error); + * .then(user => console.log(`Banned ${user.username || user.id || user} from ${guild.name}`)) + * .catch(console.error); */ ban(user, options = {}) { if (typeof options === 'number') { @@ -694,21 +831,23 @@ class Guild { /** * Unbans a user from the guild. * @param {UserResolvable} user The user to unban + * @param {string} [reason] Reason for unbanning the user * @returns {Promise} * @example * // Unban a user by ID (or with a user/guild member object) * guild.unban('some user ID') - * .then(user => console.log(`Unbanned ${user.username} from ${guild.name}`)) - * .catch(console.error); + * .then(user => console.log(`Unbanned ${user.username} from ${guild.name}`)) + * .catch(console.error); */ - unban(user) { - return this.client.rest.methods.unbanGuildMember(this, user); + unban(user, reason) { + return this.client.rest.methods.unbanGuildMember(this, user, reason); } /** * Prunes members from the guild based on how long they have been inactive. * @param {number} days Number of days of inactivity required to kick * @param {boolean} [dry=false] If true, will return number of users that will be kicked, without actually doing it + * @param {string} [reason] Reason for this prune * @returns {Promise} The number of members that were/will be kicked * @example * // See how many members will be pruned @@ -721,9 +860,9 @@ class Guild { * .then(pruned => console.log(`I just pruned ${pruned} people!`)) * .catch(console.error); */ - pruneMembers(days, dry = false) { + pruneMembers(days, dry = false, reason) { if (typeof days !== 'number') throw new TypeError('Days must be a number.'); - return this.client.rest.methods.pruneGuildMembers(this, days, dry); + return this.client.rest.methods.pruneGuildMembers(this, days, dry, reason); } /** @@ -738,16 +877,17 @@ class Guild { * Creates a new channel in the guild. * @param {string} name The name of the new channel * @param {string} type The type of the new channel, either `text` or `voice` - * @param {Array} overwrites Permission overwrites to apply to the new channel + * @param {Array} [overwrites] Permission overwrites to apply to the new channel + * @param {string} [reason] Reason for creating this channel * @returns {Promise} * @example * // Create a new text channel * guild.createChannel('new-general', 'text') - * .then(channel => console.log(`Created new channel ${channel}`)) - * .catch(console.error); + * .then(channel => console.log(`Created new channel ${channel}`)) + * .catch(console.error); */ - createChannel(name, type, overwrites) { - return this.client.rest.methods.createChannel(this, name, type, overwrites); + createChannel(name, type, overwrites, reason) { + return this.client.rest.methods.createChannel(this, name, type, overwrites, reason); } /** @@ -763,8 +903,8 @@ class Guild { * @returns {Promise} * @example * guild.updateChannels([{ channel: channelID, position: newChannelIndex }]) - * .then(guild => console.log(`Updated channel positions for ${guild.id}`)) - * .catch(console.error); + * .then(guild => console.log(`Updated channel positions for ${guild.id}`)) + * .catch(console.error); */ setChannelPositions(channelPositions) { return this.client.rest.methods.updateChannelPositions(this.id, channelPositions); @@ -773,23 +913,24 @@ class Guild { /** * Creates a new role in the guild with given information * @param {RoleData} [data] The data to update the role with + * @param {string} [reason] Reason for creating this role * @returns {Promise} * @example * // Create a new role * guild.createRole() - * .then(role => console.log(`Created role ${role}`)) - * .catch(console.error); + * .then(role => console.log(`Created role ${role}`)) + * .catch(console.error); * @example * // Create a new role with data * guild.createRole({ * name: 'Super Cool People', * color: 'BLUE', * }) - * .then(role => console.log(`Created role ${role}`)) - * .catch(console.error) + * .then(role => console.log(`Created role ${role}`)) + * .catch(console.error) */ - createRole(data = {}) { - return this.client.rest.methods.createGuildRole(this, data); + createRole(data = {}, reason) { + return this.client.rest.methods.createGuildRole(this, data, reason); } /** @@ -797,39 +938,38 @@ class Guild { * @param {BufferResolvable|Base64Resolvable} attachment The image for the emoji * @param {string} name The name for the emoji * @param {Collection|Role[]} [roles] Roles to limit the emoji to + * @param {string} [reason] Reason for creating the emoji * @returns {Promise} The created emoji * @example * // Create a new emoji from a url * guild.createEmoji('https://i.imgur.com/w3duR07.png', 'rip') - * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) - * .catch(console.error); + * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) + * .catch(console.error); * @example * // Create a new emoji from a file on your computer * guild.createEmoji('./memes/banana.png', 'banana') - * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) - * .catch(console.error); + * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`)) + * .catch(console.error); */ - createEmoji(attachment, name, roles) { - return new Promise(resolve => { - if (typeof attachment === 'string' && attachment.startsWith('data:')) { - resolve(this.client.rest.methods.createEmoji(this, attachment, name, roles)); - } else { - this.client.resolver.resolveBuffer(attachment).then(data => { - const dataURI = this.client.resolver.resolveBase64(data); - resolve(this.client.rest.methods.createEmoji(this, dataURI, name, roles)); - }); - } - }); + createEmoji(attachment, name, roles, reason) { + if (typeof attachment === 'string' && attachment.startsWith('data:')) { + return this.client.rest.methods.createEmoji(this, attachment, name, roles, reason); + } else { + return this.client.resolver.resolveImage(attachment).then(data => + this.client.rest.methods.createEmoji(this, data, name, roles, reason) + ); + } } /** * Delete an emoji. * @param {Emoji|string} emoji The emoji to delete + * @param {string} [reason] Reason for deleting the emoji * @returns {Promise} */ - deleteEmoji(emoji) { + deleteEmoji(emoji, reason) { if (!(emoji instanceof Emoji)) emoji = this.emojis.get(emoji); - return this.client.rest.methods.deleteEmoji(emoji); + return this.client.rest.methods.deleteEmoji(emoji, reason); } /** @@ -838,8 +978,8 @@ class Guild { * @example * // Leave a guild * guild.leave() - * .then(g => console.log(`Left the guild ${g}`)) - * .catch(console.error); + * .then(g => console.log(`Left the guild ${g}`)) + * .catch(console.error); */ leave() { return this.client.rest.methods.leaveGuild(this); @@ -851,8 +991,8 @@ class Guild { * @example * // Delete a guild * guild.delete() - * .then(g => console.log(`Deleted the guild ${g}`)) - * .catch(console.error); + * .then(g => console.log(`Deleted the guild ${g}`)) + * .catch(console.error); */ delete() { return this.client.rest.methods.deleteGuild(this); @@ -1063,10 +1203,22 @@ class Guild { _sortPositionWithID(collection) { return collection.sort((a, b) => a.position !== b.position ? - a.position - b.position : - Long.fromString(a.id).sub(Long.fromString(b.id)).toNumber() + a.position - b.position : + Long.fromString(a.id).sub(Long.fromString(b.id)).toNumber() ); } } +/** + * The `#general` TextChannel of the guild + * @name Guild#defaultChannel + * @type {TextChannel} + * @readonly + */ +Object.defineProperty(Guild.prototype, 'defaultChannel', { + get: util.deprecate(function defaultChannel() { + return this.channels.get(this.id); + }, 'Guild#defaultChannel: This property is obsolete, will be removed in v12.0.0, and may not function as expected.'), +}); + module.exports = Guild; diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js index 6f6e04dcd..e2be2401d 100644 --- a/src/structures/GuildAuditLogs.js +++ b/src/structures/GuildAuditLogs.js @@ -2,6 +2,7 @@ const Collection = require('../util/Collection'); const Snowflake = require('../util/Snowflake'); const Targets = { + ALL: 'ALL', GUILD: 'GUILD', CHANNEL: 'CHANNEL', USER: 'USER', @@ -9,9 +10,11 @@ const Targets = { INVITE: 'INVITE', WEBHOOK: 'WEBHOOK', EMOJI: 'EMOJI', + MESSAGE: 'MESSAGE', }; const Actions = { + ALL: null, GUILD_UPDATE: 1, CHANNEL_CREATE: 10, CHANNEL_UPDATE: 11, @@ -37,6 +40,7 @@ const Actions = { EMOJI_CREATE: 60, EMOJI_UPDATE: 61, EMOJI_DELETE: 62, + MESSAGE_DELETE: 72, }; @@ -60,13 +64,11 @@ class GuildAuditLogs { /** * Handles possible promises for entry targets. - * @returns {GuildAuditLogs} + * @returns {Promise} */ static build(...args) { - return new Promise(resolve => { - const logs = new GuildAuditLogs(...args); - Promise.all(logs.entries.map(e => e.target)).then(() => resolve(logs)); - }); + const logs = new GuildAuditLogs(...args); + return Promise.all(logs.entries.map(e => e.target)).then(() => logs); } /** @@ -82,6 +84,7 @@ class GuildAuditLogs { if (target < 50) return Targets.INVITE; if (target < 60) return Targets.WEBHOOK; if (target < 70) return Targets.EMOJI; + if (target < 80) return Targets.MESSAGE; return null; } @@ -112,6 +115,7 @@ class GuildAuditLogs { Actions.INVITE_DELETE, Actions.WEBHOOK_DELETE, Actions.EMOJI_DELETE, + Actions.MESSAGE_DELETE, ].includes(action)) return 'DELETE'; if ([ @@ -119,6 +123,7 @@ class GuildAuditLogs { Actions.CHANNEL_UPDATE, Actions.CHANNEL_OVERWRITE_UPDATE, Actions.MEMBER_UPDATE, + Actions.MEMBER_ROLE_UPDATE, Actions.ROLE_UPDATE, Actions.INVITE_UPDATE, Actions.WEBHOOK_UPDATE, @@ -166,10 +171,18 @@ class GuildAuditLogsEntry { this.executor = guild.client.users.get(data.user_id); /** - * Specific property changes - * @type {Object[]} + * An entry in the audit log representing a specific change. + * @typedef {object} AuditLogChange + * @property {string} key The property that was changed, e.g. `nick` for nickname changes + * @property {*} [old] The old value of the change, e.g. for nicknames, the old nickname + * @property {*} [new] The new value of the change, e.g. for nicknames, the new nickname */ - this.changes = data.changes ? data.changes.map(c => ({ name: c.key, old: c.old_value, new: c.new_value })) : null; + + /** + * Specific property changes + * @type {AuditLogChange[]} + */ + this.changes = data.changes ? data.changes.map(c => ({ key: c.key, old: c.old_value, new: c.new_value })) : null; /** * The ID of this entry @@ -188,15 +201,20 @@ class GuildAuditLogsEntry { removed: data.options.members_removed, days: data.options.delete_member_days, }; + } else if (data.action_type === Actions.MESSAGE_DELETE) { + this.extra = { + count: data.options.count, + channel: guild.channels.get(data.options.channel_id), + }; } else { switch (data.options.type) { case 'member': - this.extra = guild.members.get(this.options.id); - if (!this.extra) this.extra = { id: this.options.id }; + this.extra = guild.members.get(data.options.id); + if (!this.extra) this.extra = { id: data.options.id }; break; case 'role': - this.extra = guild.roles.get(this.options.id); - if (!this.extra) this.extra = { id: this.options.id, name: this.options.role_name }; + this.extra = guild.roles.get(data.options.id); + if (!this.extra) this.extra = { id: data.options.id, name: data.options.role_name }; break; default: break; @@ -217,12 +235,14 @@ class GuildAuditLogsEntry { return this.target; }); } else if (targetType === Targets.INVITE) { - const change = this.changes.find(c => c.name === 'code'); + const change = this.changes.find(c => c.key === 'code'); this.target = guild.fetchInvites() .then(invites => { - this.target = invites.find(i => i.code === (change.new || change.old)); + this.target = invites.find(i => i.code === (change.new_value || change.old_value)); return this.target; }); + } else if (targetType === Targets.MESSAGE) { + this.target = guild.client.users.get(data.target_id); } else { this.target = guild[`${targetType.toLowerCase()}s`].get(data.target_id); } diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js index 16c8ddd87..f54e60297 100644 --- a/src/structures/GuildChannel.js +++ b/src/structures/GuildChannel.js @@ -3,6 +3,7 @@ const Role = require('./Role'); const PermissionOverwrites = require('./PermissionOverwrites'); const Permissions = require('../util/Permissions'); const Collection = require('../util/Collection'); +const Constants = require('../util/Constants'); /** * Represents a guild channel (i.e. text channels and voice channels). @@ -72,6 +73,9 @@ class GuildChannel extends Channel { const roles = member.roles; for (const role of roles.values()) permissions |= role.permissions; + const admin = Boolean(permissions & Permissions.FLAGS.ADMINISTRATOR); + if (admin) return new Permissions(Permissions.ALL); + const overwrites = this.overwritesFor(member, true, roles); if (overwrites.everyone) { @@ -91,9 +95,6 @@ class GuildChannel extends Channel { permissions |= overwrites.member.allow; } - const admin = Boolean(permissions & Permissions.FLAGS.ADMINISTRATOR); - if (admin) permissions = Permissions.ALL; - return new Permissions(member, permissions); } @@ -136,18 +137,19 @@ class GuildChannel extends Channel { /** * Overwrites the permissions for a user or role in this channel. - * @param {RoleResolvable|UserResolvable} userOrRole The user or role to update + * @param {Role|Snowflake|UserResolvable} userOrRole The user or role to update * @param {PermissionOverwriteOptions} options The configuration for the update + * @param {string} [reason] Reason for creating/editing this overwrite * @returns {Promise} * @example * // Overwrite permissions for a message author * message.channel.overwritePermissions(message.author, { - * SEND_MESSAGES: false + * SEND_MESSAGES: false * }) - * .then(() => console.log('Done!')) - * .catch(console.error); + * .then(() => console.log('Done!')) + * .catch(console.error); */ - overwritePermissions(userOrRole, options) { + overwritePermissions(userOrRole, options, reason) { const payload = { allow: 0, deny: 0, @@ -186,7 +188,7 @@ class GuildChannel extends Channel { } } - return this.client.rest.methods.setChannelOverwrite(this, payload); + return this.client.rest.methods.setChannelOverwrite(this, payload, reason); } /** @@ -202,29 +204,31 @@ class GuildChannel extends Channel { /** * Edits the channel. * @param {ChannelData} data The new data for the channel + * @param {string} [reason] Reason for editing this channel * @returns {Promise} * @example * // Edit a channel * channel.edit({name: 'new-channel'}) - * .then(c => console.log(`Edited channel ${c}`)) - * .catch(console.error); + * .then(c => console.log(`Edited channel ${c}`)) + * .catch(console.error); */ - edit(data) { - return this.client.rest.methods.updateChannel(this, data); + edit(data, reason) { + return this.client.rest.methods.updateChannel(this, data, reason); } /** * Set a new name for the guild channel. * @param {string} name The new name for the guild channel + * @param {string} [reason] Reason for changing the guild channel's name * @returns {Promise} * @example * // Set a new channel name * channel.setName('not_general') - * .then(newChannel => console.log(`Channel's new name is ${newChannel.name}`)) - * .catch(console.error); + * .then(newChannel => console.log(`Channel's new name is ${newChannel.name}`)) + * .catch(console.error); */ - setName(name) { - return this.edit({ name }); + setName(name, reason) { + return this.edit({ name }, reason); } /** @@ -235,8 +239,8 @@ class GuildChannel extends Channel { * @example * // Set a new channel position * channel.setPosition(2) - * .then(newChannel => console.log(`Channel's new position is ${newChannel.position}`)) - * .catch(console.error); + * .then(newChannel => console.log(`Channel's new position is ${newChannel.position}`)) + * .catch(console.error); */ setPosition(position, relative) { return this.guild.setChannelPosition(this, position, relative).then(() => this); @@ -245,34 +249,32 @@ class GuildChannel extends Channel { /** * Set a new topic for the guild channel. * @param {string} topic The new topic for the guild channel + * @param {string} [reason] Reason for changing the guild channel's topic * @returns {Promise} * @example * // Set a new channel topic * channel.setTopic('needs more rate limiting') - * .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`)) - * .catch(console.error); + * .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`)) + * .catch(console.error); */ - setTopic(topic) { - return this.client.rest.methods.updateChannel(this, { topic }); + setTopic(topic, reason) { + return this.edit({ topic }, reason); } - /** - * Options given when creating a guild channel invite. - * @typedef {Object} InviteOptions - - */ - /** * Create an invite to this guild channel. - * @param {InviteOptions} [options={}] Options for the invite + * This is only available when using a bot account. + * @param {Object} [options={}] Options for the invite * @param {boolean} [options.temporary=false] Whether members that joined via the invite should be automatically * kicked after 24 hours if they have not yet received a role * @param {number} [options.maxAge=86400] How long the invite should last (in seconds, 0 for forever) * @param {number} [options.maxUses=0] Maximum number of uses + * @param {boolean} [options.unique=false] Create a unique invite, or use an existing one with similar settings + * @param {string} [reason] Reason for creating the invite * @returns {Promise} */ - createInvite(options = {}) { - return this.client.rest.methods.createChannelInvite(this, options); + createInvite(options = {}, reason) { + return this.client.rest.methods.createChannelInvite(this, options, reason); } /** @@ -280,13 +282,28 @@ class GuildChannel extends Channel { * @param {string} [name=this.name] Optional name for the new channel, otherwise it has the name of this channel * @param {boolean} [withPermissions=true] Whether to clone the channel with this channel's permission overwrites * @param {boolean} [withTopic=true] Whether to clone the channel with this channel's topic + * @param {string} [reason] Reason for cloning this channel * @returns {Promise} */ - clone(name = this.name, withPermissions = true, withTopic = true) { - return this.guild.createChannel(name, this.type, withPermissions ? this.permissionOverwrites : []) + clone(name = this.name, withPermissions = true, withTopic = true, reason) { + return this.guild.createChannel(name, this.type, withPermissions ? this.permissionOverwrites : [], reason) .then(channel => withTopic ? channel.setTopic(this.topic) : channel); } + /** + * Deletes this channel. + * @param {string} [reason] Reason for deleting this channel + * @returns {Promise} + * @example + * // Delete the channel + * channel.delete('making room for new channels') + * .then(channel => console.log(`Deleted ${channel.name} to make room for new channels`)) + * .catch(console.error); // Log error + */ + delete(reason) { + return this.client.rest.methods.deleteChannel(this, reason); + } + /** * Checks if this channel has the same type, topic, position, name, overwrites and ID as another channel. * In most cases, a simple `channel.id === channel2.id` will do, and is much faster too. @@ -322,6 +339,36 @@ class GuildChannel extends Channel { this.permissionsFor(this.client.user).has(Permissions.FLAGS.MANAGE_CHANNELS); } + /** + * Whether the channel is muted + * This is only available when using a user account. + * @type {?boolean} + * @readonly + */ + get muted() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.guild.id).channelOverrides.get(this.id).muted; + } catch (err) { + return false; + } + } + + /** + * The type of message that should notify you + * This is only available when using a user account. + * @type {?MessageNotificationType} + * @readonly + */ + get messageNotifications() { + if (this.client.user.bot) return null; + try { + return this.client.user.guildSettings.get(this.guild.id).channelOverrides.get(this.id).messageNotifications; + } catch (err) { + return Constants.MessageNotificationTypes[3]; + } + } + /** * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object. * @returns {string} diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index bcad0e03e..bf87bb9ef 100644 --- a/src/structures/GuildMember.js +++ b/src/structures/GuildMember.js @@ -345,28 +345,31 @@ class GuildMember { /** * Edit a guild member. * @param {GuildMemberEditData} data The data to edit the member with + * @param {string} [reason] Reason for editing this user * @returns {Promise} */ - edit(data) { - return this.client.rest.methods.updateGuildMember(this, data); + edit(data, reason) { + return this.client.rest.methods.updateGuildMember(this, data, reason); } /** * Mute/unmute a user. * @param {boolean} mute Whether or not the member should be muted + * @param {string} [reason] Reason for muting or unmuting * @returns {Promise} */ - setMute(mute) { - return this.edit({ mute }); + setMute(mute, reason) { + return this.edit({ mute }, reason); } /** * Deafen/undeafen a user. * @param {boolean} deaf Whether or not the member should be deafened + * @param {string} [reason] Reason for deafening or undeafening * @returns {Promise} */ - setDeaf(deaf) { - return this.edit({ deaf }); + setDeaf(deaf, reason) { + return this.edit({ deaf }, reason); } /** @@ -381,29 +384,32 @@ class GuildMember { /** * Sets the roles applied to the member. * @param {Collection|Role[]|Snowflake[]} roles The roles or role IDs to apply + * @param {string} [reason] Reason for applying the roles * @returns {Promise} */ - setRoles(roles) { - return this.edit({ roles }); + setRoles(roles, reason) { + return this.edit({ roles }, reason); } /** * Adds a single role to the member. * @param {Role|Snowflake} role The role or ID of the role to add + * @param {string} [reason] Reason for adding the role * @returns {Promise} */ - addRole(role) { + addRole(role, reason) { if (!(role instanceof Role)) role = this.guild.roles.get(role); - if (!role) throw new TypeError('Supplied parameter was neither a Role nor a Snowflake.'); - return this.client.rest.methods.addMemberRole(this, role); + if (!role) return Promise.reject(new TypeError('Supplied parameter was neither a Role nor a Snowflake.')); + return this.client.rest.methods.addMemberRole(this, role, reason); } /** * Adds multiple roles to the member. * @param {Collection|Role[]|Snowflake[]} roles The roles or role IDs to add + * @param {string} [reason] Reason for adding the roles * @returns {Promise} */ - addRoles(roles) { + addRoles(roles, reason) { let allRoles; if (roles instanceof Collection) { allRoles = this._roles.slice(); @@ -411,26 +417,28 @@ class GuildMember { } else { allRoles = this._roles.concat(roles); } - return this.edit({ roles: allRoles }); + return this.edit({ roles: allRoles }, reason); } /** * Removes a single role from the member. * @param {Role|Snowflake} role The role or ID of the role to remove + * @param {string} [reason] Reason for removing the role * @returns {Promise} */ - removeRole(role) { + removeRole(role, reason) { if (!(role instanceof Role)) role = this.guild.roles.get(role); - if (!role) throw new TypeError('Supplied parameter was neither a Role nor a Snowflake.'); - return this.client.rest.methods.removeMemberRole(this, role); + if (!role) return Promise.reject(new TypeError('Supplied parameter was neither a Role nor a Snowflake.')); + return this.client.rest.methods.removeMemberRole(this, role, reason); } /** * Removes multiple roles from the member. * @param {Collection|Role[]|Snowflake[]} roles The roles or role IDs to remove + * @param {string} [reason] Reason for removing the roles * @returns {Promise} */ - removeRoles(roles) { + removeRoles(roles, reason) { const allRoles = this._roles.slice(); if (roles instanceof Collection) { for (const role of roles.values()) { @@ -443,16 +451,17 @@ class GuildMember { if (index >= 0) allRoles.splice(index, 1); } } - return this.edit({ roles: allRoles }); + return this.edit({ roles: allRoles }, reason); } /** * Set the nickname for the guild member. * @param {string} nick The nickname for the guild member + * @param {string} [reason] Reason for setting the nickname * @returns {Promise} */ - setNickname(nick) { - return this.edit({ nick }); + setNickname(nick, reason) { + return this.edit({ nick }, reason); } /** @@ -481,7 +490,7 @@ class GuildMember { } /** - * Ban this guild member + * Ban 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 {number} [options.days=0] Number of days of messages to delete diff --git a/src/structures/Invite.js b/src/structures/Invite.js index 8a7b962cd..5582f0941 100644 --- a/src/structures/Invite.js +++ b/src/structures/Invite.js @@ -2,27 +2,6 @@ const PartialGuild = require('./PartialGuild'); const PartialGuildChannel = require('./PartialGuildChannel'); const Constants = require('../util/Constants'); -/* -{ max_age: 86400, - code: 'CG9A5', - guild: - { splash: null, - id: '123123123', - icon: '123123123', - name: 'name' }, - created_at: '2016-08-28T19:07:04.763368+00:00', - temporary: false, - uses: 0, - max_uses: 0, - inviter: - { username: '123', - discriminator: '4204', - bot: true, - id: '123123123', - avatar: '123123123' }, - channel: { type: 0, id: '123123', name: 'heavy-testing' } } -*/ - /** * Represents an invitation to a guild channel. * The only guaranteed properties are `code`, `guild` and `channel`. Other properties can be missing. @@ -54,6 +33,30 @@ class Invite { */ this.code = data.code; + /** + * The approximate number of online members of the guild this invite is for + * @type {number} + */ + this.presenceCount = data.approximate_presence_count; + + /** + * The approximate total number of members of the guild this invite is for + * @type {number} + */ + this.memberCount = data.approximate_member_count; + + /** + * The number of text channels the guild this invite goes to has + * @type {number} + */ + this.textChannelCount = data.guild.text_channel_count; + + /** + * The number of voice channels the guild this invite goes to has + * @type {number} + */ + this.voiceChannelCount = data.guild.voice_channel_count; + /** * Whether or not this invite is temporary * @type {boolean} @@ -138,10 +141,11 @@ class Invite { /** * Deletes this invite. + * @param {string} [reason] Reason for deleting this invite * @returns {Promise} */ - delete() { - return this.client.rest.methods.deleteInvite(this); + delete(reason) { + return this.client.rest.methods.deleteInvite(this, reason); } /** diff --git a/src/structures/Message.js b/src/structures/Message.js index dfd5fdd2e..ce83c63a9 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -1,6 +1,7 @@ const Mentions = require('./MessageMentions'); const Attachment = require('./MessageAttachment'); const Embed = require('./MessageEmbed'); +const RichEmbed = require('./RichEmbed'); const MessageReaction = require('./MessageReaction'); const ReactionCollector = require('./ReactionCollector'); const Util = require('../util/Util'); @@ -33,7 +34,7 @@ class Message { setup(data) { // eslint-disable-line complexity /** - * The ID of the message (unique in the channel it was sent) + * The ID of the message * @type {Snowflake} */ this.id = data.id; @@ -57,8 +58,8 @@ class Message { this.author = this.client.dataManager.newUser(data.author); /** - * Represents the author of the message as a guild member. Only available if the message comes from a guild - * where the author is still a member. + * Represents the author of the message as a guild member + * Only available if the message comes from a guild where the author is still a member * @type {?GuildMember} */ this.member = this.guild ? this.guild.member(this.author) || null : null; @@ -209,8 +210,8 @@ class Message { } /** - * The message contents with all mentions replaced by the equivalent text. If mentions cannot be resolved to a name, - * the relevant mention in the message content will not be converted + * The message contents with all mentions replaced by the equivalent text. + * If mentions cannot be resolved to a name, the relevant mention in the message content will not be converted. * @type {string} * @readonly */ @@ -254,8 +255,8 @@ class Message { * @example * // Create a reaction collector * const collector = message.createReactionCollector( - * (reaction, user) => reaction.emoji.id === '👌' && user.id === 'someID', - * { time: 15000 } + * (reaction, user) => reaction.emoji.name === '👌' && user.id === 'someID', + * { time: 15000 } * ); * collector.on('collect', r => console.log(`Collected ${r.emoji.name}`)); * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); @@ -271,8 +272,8 @@ class Message { */ /** - * Similar to createCollector but in promise form. Resolves with a collection of reactions that pass the specified - * filter. + * Similar to createMessageCollector but in promise form. + * Resolves with a collection of reactions that pass the specified filter. * @param {CollectorFilter} filter The filter function to use * @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector * @returns {Promise>} @@ -365,13 +366,13 @@ class Message { /** * Edit the content of the message. * @param {StringResolvable} [content] The new content for the message - * @param {MessageEditOptions} [options] The options to provide + * @param {MessageEditOptions|RichEmbed} [options] The options to provide * @returns {Promise} * @example * // Update the content of a message * message.edit('This is my new content!') - * .then(msg => console.log(`Updated the content of a message from ${msg.author}`)) - * .catch(console.error); + * .then(msg => console.log(`Updated the content of a message from ${msg.author}`)) + * .catch(console.error); */ edit(content, options) { if (!options && typeof content === 'object' && !(content instanceof Array)) { @@ -380,6 +381,7 @@ class Message { } else if (!options) { options = {}; } + if (options instanceof RichEmbed) options = { embed: options }; return this.client.rest.methods.updateMessage(this, content, options); } @@ -388,6 +390,7 @@ class Message { * @param {string} lang The language for the code block * @param {StringResolvable} content The new content for the message * @returns {Promise} + * @deprecated */ editCode(lang, content) { content = Util.escapeMarkdown(this.client.resolver.resolveString(content), true); @@ -437,8 +440,8 @@ class Message { * @example * // Delete a message * message.delete() - * .then(msg => console.log(`Deleted message from ${msg.author}`)) - * .catch(console.error); + * .then(msg => console.log(`Deleted message from ${msg.author}`)) + * .catch(console.error); */ delete(timeout = 0) { if (timeout <= 0) { @@ -460,8 +463,8 @@ class Message { * @example * // Reply to a message * message.reply('Hey, I\'m a reply!') - * .then(msg => console.log(`Sent a reply to ${msg.author}`)) - * .catch(console.error); + * .then(msg => console.log(`Sent a reply to ${msg.author}`)) + * .catch(console.error); */ reply(content, options) { if (!options && typeof content === 'object' && !(content instanceof Array)) { @@ -533,7 +536,7 @@ class Message { } _addReaction(emoji, user) { - const emojiID = emoji.id ? `${emoji.name}:${emoji.id}` : encodeURIComponent(emoji.name); + const emojiID = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; let reaction; if (this.reactions.has(emojiID)) { reaction = this.reactions.get(emojiID); @@ -542,13 +545,15 @@ class Message { reaction = new MessageReaction(this, emoji, 0, user.id === this.client.user.id); this.reactions.set(emojiID, reaction); } - if (!reaction.users.has(user.id)) reaction.users.set(user.id, user); - reaction.count++; + if (!reaction.users.has(user.id)) { + reaction.users.set(user.id, user); + reaction.count++; + } return reaction; } _removeReaction(emoji, user) { - const emojiID = emoji.id ? `${emoji.name}:${emoji.id}` : encodeURIComponent(emoji.name); + const emojiID = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; if (this.reactions.has(emojiID)) { const reaction = this.reactions.get(emojiID); if (reaction.users.has(user.id)) { diff --git a/src/structures/MessageCollector.js b/src/structures/MessageCollector.js index fd5bebda4..c0a51c813 100644 --- a/src/structures/MessageCollector.js +++ b/src/structures/MessageCollector.js @@ -12,7 +12,6 @@ const util = require('util'); * @extends {Collector} */ class MessageCollector extends Collector { - /** * @param {TextChannel|DMChannel|GroupDMChannel} channel The channel * @param {CollectorFilter} filter The filter to be applied to this collector @@ -23,7 +22,8 @@ class MessageCollector extends Collector { super(channel.client, filter, options); /** - * @type {TextBasedChannel} channel The channel + * The channel + * @type {TextBasedChannel} */ this.channel = channel; @@ -61,7 +61,7 @@ class MessageCollector extends Collector { /** * Handle an incoming message for possible collection. * @param {Message} message The message that could be collected - * @returns {?{key: Snowflake, value: Message}} Message data to collect + * @returns {?{key: Snowflake, value: Message}} * @private */ handle(message) { diff --git a/src/structures/MessageMentions.js b/src/structures/MessageMentions.js index b045d5a70..99d45913b 100644 --- a/src/structures/MessageMentions.js +++ b/src/structures/MessageMentions.js @@ -102,7 +102,7 @@ class MessageMentions { /** * Any channels that were mentioned - * @type {?Collection} + * @type {Collection} * @readonly */ get channels() { @@ -124,7 +124,7 @@ class MessageMentions { MessageMentions.EVERYONE_PATTERN = /@(everyone|here)/g; /** - * Regular expression that globally matches user mentions like `<#81440962496172032>` + * Regular expression that globally matches user mentions like `<@81440962496172032>` * @type {RegExp} */ MessageMentions.USERS_PATTERN = /<@!?[0-9]+>/g; diff --git a/src/structures/OAuth2Application.js b/src/structures/OAuth2Application.js index 0e037ab7d..cfd657d95 100644 --- a/src/structures/OAuth2Application.js +++ b/src/structures/OAuth2Application.js @@ -37,7 +37,7 @@ class OAuth2Application { /** * The app's icon hash - * @type {string} + * @type {?string} */ this.icon = data.icon; @@ -124,6 +124,7 @@ class OAuth2Application { /** * Reset the app's secret and bot token. + * This is only available when using a user account. * @returns {OAuth2Application} */ reset() { diff --git a/src/structures/PermissionOverwrites.js b/src/structures/PermissionOverwrites.js index 8044be45a..efe9e956b 100644 --- a/src/structures/PermissionOverwrites.js +++ b/src/structures/PermissionOverwrites.js @@ -33,10 +33,11 @@ class PermissionOverwrites { /** * Delete this Permission Overwrite. + * @param {string} [reason] Reason for deleting this overwrite * @returns {Promise} */ - delete() { - return this.channel.client.rest.methods.deletePermissionOverwrites(this); + delete(reason) { + return this.channel.client.rest.methods.deletePermissionOverwrites(this, reason); } } diff --git a/src/structures/ReactionCollector.js b/src/structures/ReactionCollector.js index 5e0b7d5a1..ecdb37674 100644 --- a/src/structures/ReactionCollector.js +++ b/src/structures/ReactionCollector.js @@ -13,7 +13,6 @@ const Collection = require('../util/Collection'); * @extends {Collector} */ class ReactionCollector extends Collector { - /** * @param {Message} message The message upon which to collect reactions * @param {CollectorFilter} filter The filter to apply to this collector @@ -46,7 +45,7 @@ class ReactionCollector extends Collector { /** * Handle an incoming reaction for possible collection. * @param {MessageReaction} reaction The reaction to possibly collect - * @returns {?{key: Snowflake, value: MessageReaction}} Reaction data to collect + * @returns {?{key: Snowflake, value: MessageReaction}} * @private */ handle(reaction) { diff --git a/src/structures/ReactionEmoji.js b/src/structures/ReactionEmoji.js index c8e67b570..f550544c6 100644 --- a/src/structures/ReactionEmoji.js +++ b/src/structures/ReactionEmoji.js @@ -38,7 +38,7 @@ class ReactionEmoji { * Creates the text required to form a graphical emoji on Discord. * @example * // Send the emoji used in a reaction to the channel the reaction is part of - * reaction.message.channel.sendMessage(`The emoji used is ${reaction.emoji}`); + * reaction.message.channel.send(`The emoji used is ${reaction.emoji}`); * @returns {string} */ toString() { diff --git a/src/structures/RichEmbed.js b/src/structures/RichEmbed.js index 62b40e185..8842f976d 100644 --- a/src/structures/RichEmbed.js +++ b/src/structures/RichEmbed.js @@ -1,4 +1,5 @@ -const ClientDataResolver = require('../client/ClientDataResolver'); +const Attachment = require('./Attachment'); +let ClientDataResolver; /** * A rich embed to be sent with a message with a fluent interface for creation. @@ -68,7 +69,7 @@ class RichEmbed { /** * File to upload alongside this Embed - * @type {string} + * @type {FileOptions|string|Attachment} */ this.file = data.file; } @@ -113,6 +114,7 @@ class RichEmbed { * @returns {RichEmbed} This embed */ setColor(color) { + if (!ClientDataResolver) ClientDataResolver = require('../client/ClientDataResolver'); this.color = ClientDataResolver.resolveColor(color); return this; } @@ -203,11 +205,13 @@ class RichEmbed { /** * Sets the file to upload alongside the embed. This file can be accessed via `attachment://fileName.extension` when * setting an embed image or author/footer icons. Only one file may be attached. - * @param {FileOptions|string} file Local path or URL to the file to attach, or valid FileOptions for a file to attach + * @param {FileOptions|string|Attachment} file Local path or URL to the file to attach, + * or valid FileOptions for a file to attach * @returns {RichEmbed} This embed */ attachFile(file) { if (this.file) throw new RangeError('You may not upload more than one file at once.'); + if (file instanceof Attachment) file = file.file; this.file = file; return this; } diff --git a/src/structures/Role.js b/src/structures/Role.js index d5ffb33b1..d7a787f0d 100644 --- a/src/structures/Role.js +++ b/src/structures/Role.js @@ -135,7 +135,7 @@ class Role { } /** - * Get an object mapping permission names to whether or not the role enables that permission + * Get an object mapping permission names to whether or not the role enables that permission. * @returns {Object} * @example * // Print the serialized role permissions @@ -195,64 +195,68 @@ class Role { * @property {ColorResolvable} [color] The color of the role, either a hex string or a base 10 number * @property {boolean} [hoist] Whether or not the role should be hoisted * @property {number} [position] The position of the role - * @property {string[]} [permissions] The permissions of the role + * @property {PermissionResolvable[]|number} [permissions] The permissions of the role * @property {boolean} [mentionable] Whether or not the role should be mentionable */ /** * Edits the role. * @param {RoleData} data The new data for the role + * @param {string} [reason] The reason for editing this role * @returns {Promise} * @example * // Edit a role * role.edit({name: 'new role'}) - * .then(r => console.log(`Edited role ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Edited role ${r}`)) + * .catch(console.error); */ - edit(data) { - return this.client.rest.methods.updateGuildRole(this, data); + edit(data, reason) { + return this.client.rest.methods.updateGuildRole(this, data, reason); } /** * Set a new name for the role. * @param {string} name The new name of the role + * @param {string} [reason] Reason for changing the role's name * @returns {Promise} * @example * // Set the name of the role * role.setName('new role') - * .then(r => console.log(`Edited name of role ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Edited name of role ${r}`)) + * .catch(console.error); */ - setName(name) { - return this.edit({ name }); + setName(name, reason) { + return this.edit({ name }, reason); } /** * Set a new color for the role. * @param {ColorResolvable} color The color of the role + * @param {string} [reason] Reason for changing the role's color * @returns {Promise} * @example * // Set the color of a role * role.setColor('#FF0000') - * .then(r => console.log(`Set color of role ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Set color of role ${r}`)) + * .catch(console.error); */ - setColor(color) { - return this.edit({ color }); + setColor(color, reason) { + return this.edit({ color }, reason); } /** * Set whether or not the role should be hoisted. * @param {boolean} hoist Whether or not to hoist the role + * @param {string} [reason] Reason for setting whether or not the role should be hoisted * @returns {Promise} * @example * // Set the hoist of the role * role.setHoist(true) - * .then(r => console.log(`Role hoisted: ${r.hoist}`)) - * .catch(console.error); + * .then(r => console.log(`Role hoisted: ${r.hoist}`)) + * .catch(console.error); */ - setHoist(hoist) { - return this.edit({ hoist }); + setHoist(hoist, reason) { + return this.edit({ hoist }, reason); } /** @@ -263,8 +267,8 @@ class Role { * @example * // Set the position of the role * role.setPosition(1) - * .then(r => console.log(`Role position: ${r.position}`)) - * .catch(console.error); + * .then(r => console.log(`Role position: ${r.position}`)) + * .catch(console.error); */ setPosition(position, relative) { return this.guild.setRolePosition(this, position, relative).then(() => this); @@ -273,42 +277,45 @@ class Role { /** * Set the permissions of the role. * @param {string[]} permissions The permissions of the role + * @param {string} [reason] Reason for changing the role's permissions * @returns {Promise} * @example * // Set the permissions of the role * role.setPermissions(['KICK_MEMBERS', 'BAN_MEMBERS']) - * .then(r => console.log(`Role updated ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Role updated ${r}`)) + * .catch(console.error); */ - setPermissions(permissions) { - return this.edit({ permissions }); + setPermissions(permissions, reason) { + return this.edit({ permissions }, reason); } /** * Set whether this role is mentionable. * @param {boolean} mentionable Whether this role should be mentionable + * @param {string} [reason] Reason for setting whether or not this role should be mentionable * @returns {Promise} * @example * // Make the role mentionable * role.setMentionable(true) - * .then(r => console.log(`Role updated ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Role updated ${r}`)) + * .catch(console.error); */ - setMentionable(mentionable) { - return this.edit({ mentionable }); + setMentionable(mentionable, reason) { + return this.edit({ mentionable }, reason); } /** * Deletes the role. + * @param {string} [reason] Reason for deleting the role * @returns {Promise} * @example * // Delete a role * role.delete() - * .then(r => console.log(`Deleted role ${r}`)) - * .catch(console.error); + * .then(r => console.log(`Deleted role ${r}`)) + * .catch(console.error); */ - delete() { - return this.client.rest.methods.deleteGuildRole(this); + delete(reason) { + return this.client.rest.methods.deleteGuildRole(this, reason); } /** diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js index 20b3c54f3..da0696840 100644 --- a/src/structures/TextChannel.js +++ b/src/structures/TextChannel.js @@ -24,6 +24,13 @@ class TextChannel extends GuildChannel { */ this.topic = data.topic; + /** + * If the Discord considers this channel NSFW + * @type {boolean} + * @readonly + */ + this.nsfw = Boolean(data.nsfw); + this.lastMessageID = data.last_message_id; } @@ -42,15 +49,6 @@ class TextChannel extends GuildChannel { return members; } - /** - * If the Discord considers this channel NSFW - * @type {boolean} - * @readonly - */ - get nsfw() { - return /^nsfw(-|$)/.test(this.name); - } - /** * Fetch all webhooks for the channel. * @returns {Promise>} @@ -62,23 +60,22 @@ class TextChannel extends GuildChannel { /** * Create a webhook for the channel. * @param {string} name The name of the webhook - * @param {BufferResolvable|Base64Resolvable} avatar The avatar for the webhook + * @param {BufferResolvable|Base64Resolvable} [avatar] The avatar for the webhook + * @param {string} [reason] Reason for creating this webhook * @returns {Promise} webhook The created webhook * @example - * channel.createWebhook('Snek', 'http://snek.s3.amazonaws.com/topSnek.png') - * .then(webhook => console.log(`Created webhook ${webhook}`)) - * .catch(console.error) + * channel.createWebhook('Snek', 'https://i.imgur.com/mI8XcpG.jpg') + * .then(webhook => console.log(`Created webhook ${webhook}`)) + * .catch(console.error) */ - createWebhook(name, avatar) { - return new Promise(resolve => { - if (typeof avatar === 'string' && avatar.startsWith('data:')) { - resolve(this.client.rest.methods.createWebhook(this, name, avatar)); - } else { - this.client.resolver.resolveBuffer(avatar).then(data => - resolve(this.client.rest.methods.createWebhook(this, name, data)) - ); - } - }); + createWebhook(name, avatar, reason) { + if (typeof avatar === 'string' && avatar.startsWith('data:')) { + return this.client.rest.methods.createWebhook(this, name, avatar, reason); + } else { + return this.client.resolver.resolveImage(avatar).then(data => + this.client.rest.methods.createWebhook(this, name, data, reason) + ); + } } // These are here only for documentation purposes - they are implemented by TextBasedChannel diff --git a/src/structures/User.js b/src/structures/User.js index 4dbc0d84f..027eedbb6 100644 --- a/src/structures/User.js +++ b/src/structures/User.js @@ -10,9 +10,9 @@ const Snowflake = require('../util/Snowflake'); class User { constructor(client, data) { /** - * The client that created the instance of the the user + * The client that created the instance of the user * @name User#client - * @type {} + * @type {Client} * @readonly */ Object.defineProperty(this, 'client', { value: client }); diff --git a/src/structures/UserProfile.js b/src/structures/UserProfile.js index 192bc1e63..a27d4d08d 100644 --- a/src/structures/UserProfile.js +++ b/src/structures/UserProfile.js @@ -13,7 +13,7 @@ class UserProfile { this.user = user; /** - * The client that created the instance of the the UserProfile. + * The client that created the instance of the UserProfile * @name UserProfile#client * @type {Client} * @readonly diff --git a/src/structures/VoiceChannel.js b/src/structures/VoiceChannel.js index 617b4eee0..fe7d7ddbc 100644 --- a/src/structures/VoiceChannel.js +++ b/src/structures/VoiceChannel.js @@ -25,7 +25,7 @@ class VoiceChannel extends GuildChannel { * The bitrate of this voice channel * @type {number} */ - this.bitrate = data.bitrate; + this.bitrate = data.bitrate * 0.001; /** * The maximum amount of users allowed in this channel - 0 means unlimited. @@ -76,31 +76,34 @@ class VoiceChannel extends GuildChannel { } /** - * Sets the bitrate of the channel. + * Sets the bitrate of the channel (in kbps). * @param {number} bitrate The new bitrate + * @param {string} [reason] Reason for changing the channel's bitrate * @returns {Promise} * @example * // Set the bitrate of a voice channel - * voiceChannel.setBitrate(48000) - * .then(vc => console.log(`Set bitrate to ${vc.bitrate} for ${vc.name}`)) - * .catch(console.error); + * voiceChannel.setBitrate(48) + * .then(vc => console.log(`Set bitrate to ${vc.bitrate}kbps for ${vc.name}`)) + * .catch(console.error); */ - setBitrate(bitrate) { - return this.edit({ bitrate }); + setBitrate(bitrate, reason) { + bitrate *= 1000; + return this.edit({ bitrate }, reason); } /** * Sets the user limit of the channel. * @param {number} userLimit The new user limit + * @param {string} [reason] Reason for changing the user limit * @returns {Promise} * @example * // Set the user limit of a voice channel * voiceChannel.setUserLimit(42) - * .then(vc => console.log(`Set user limit to ${vc.userLimit} for ${vc.name}`)) - * .catch(console.error); + * .then(vc => console.log(`Set user limit to ${vc.userLimit} for ${vc.name}`)) + * .catch(console.error); */ - setUserLimit(userLimit) { - return this.edit({ userLimit }); + setUserLimit(userLimit, reason) { + return this.edit({ userLimit }, reason); } /** @@ -109,8 +112,8 @@ class VoiceChannel extends GuildChannel { * @example * // Join a voice channel * voiceChannel.join() - * .then(connection => console.log('Connected!')) - * .catch(console.error); + * .then(connection => console.log('Connected!')) + * .catch(console.error); */ join() { if (this.client.browser) return Promise.reject(new Error('Voice connections are not available in browsers.')); diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js index 4d96382ac..1b72530e7 100644 --- a/src/structures/Webhook.js +++ b/src/structures/Webhook.js @@ -1,4 +1,7 @@ const path = require('path'); +const Util = require('../util/Util'); +const Attachment = require('./Attachment'); +const RichEmbed = require('./RichEmbed'); /** * Represents a webhook. @@ -36,7 +39,7 @@ class Webhook { /** * The avatar for the webhook - * @type {string} + * @type {?string} */ this.avatar = data.avatar; @@ -76,11 +79,12 @@ class Webhook { * @property {string} [avatarURL] Avatar URL override for the message * @property {boolean} [tts=false] Whether or not the message should be spoken aloud * @property {string} [nonce=''] The nonce for the message - * @property {Object[]} [embeds] An array of embeds for the message + * @property {Array} [embeds] An array of embeds for the message * (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|string} [file] A file to send with the message + * @property {FileOptions|BufferResolvable|Attachment} [file] A file to send with the message **(deprecated)** + * @property {FileOptions[]|BufferResolvable[]|Attachment[]} [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 * it exceeds the character limit. If an object is provided, these are the options for splitting the message. @@ -89,39 +93,87 @@ class Webhook { /** * Send a message with this webhook. * @param {StringResolvable} content The content to send - * @param {WebhookMessageOptions} [options={}] The options to provide + * @param {WebhookMessageOptions|Attachment|RichEmbed} [options] The options to provide + * can also be just a RichEmbed or Attachment * @returns {Promise} * @example * // Send a message * webhook.send('hello!') - * .then(message => console.log(`Sent message: ${message.content}`)) - * .catch(console.error); + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); */ - send(content, options) { + send(content, options) { // eslint-disable-line complexity if (!options && typeof content === 'object' && !(content instanceof Array)) { options = content; content = ''; } else if (!options) { options = {}; } - if (options.file) { - if (typeof options.file === 'string') options.file = { attachment: options.file }; - if (!options.file.name) { - if (typeof options.file.attachment === 'string') { - options.file.name = path.basename(options.file.attachment); - } else if (options.file.attachment && options.file.attachment.path) { - options.file.name = path.basename(options.file.attachment.path); - } else { - options.file.name = 'file.jpg'; + + if (options instanceof Attachment) options = { files: [options] }; + if (options instanceof RichEmbed) options = { embeds: [options] }; + + if (content) { + content = this.client.resolver.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```'; } } - return this.client.resolver.resolveBuffer(options.file.attachment).then(file => - this.client.rest.methods.sendWebhookMessage(this, content, options, { - file, - name: options.file.name, - }) - ); + if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) { + content = content.replace(/@(everyone|here)/g, '@\u200b$1'); + } + + if (split) content = Util.splitMessage(content, split); } + + if (options.file) { + if (options.files) options.files.push(options.file); + else options.files = [options.file]; + } + + if (options.embeds) { + const files = []; + for (const embed of options.embeds) { + if (embed.file) files.push(embed.file); + } + if (options.files) options.files.push(...files); + else options.files = files; + } + + if (options.files) { + for (let i = 0; i < options.files.length; i++) { + let file = options.files[i]; + if (typeof file === 'string' || Buffer.isBuffer(file)) file = { attachment: file }; + if (!file.name) { + if (typeof file.attachment === 'string') { + file.name = path.basename(file.attachment); + } else if (file.attachment && file.attachment.path) { + file.name = path.basename(file.attachment.path); + } else if (file instanceof Attachment) { + file = { attachment: file.file, name: path.basename(file.file) || 'file.jpg' }; + } else { + file.name = 'file.jpg'; + } + } else if (file instanceof Attachment) { + file = file.file; + } + options.files[i] = file; + } + + return Promise.all(options.files.map(file => + this.client.resolver.resolveFile(file.attachment).then(resource => { + file.file = resource; + return file; + }) + )).then(files => this.client.rest.methods.sendWebhookMessage(this, content, options, files)); + } + return this.client.rest.methods.sendWebhookMessage(this, content, options); } @@ -130,6 +182,7 @@ class Webhook { * @param {StringResolvable} content The content to send * @param {WebhookMessageOptions} [options={}] The options to provide * @returns {Promise} + * @deprecated * @example * // Send a message * webhook.sendMessage('hello!') @@ -147,6 +200,7 @@ class Webhook { * @param {StringResolvable} [content] Text message to send with the attachment * @param {WebhookMessageOptions} [options] The options to provide * @returns {Promise} + * @deprecated */ sendFile(attachment, name, content, options = {}) { return this.send(content, Object.assign(options, { file: { attachment, name } })); @@ -158,6 +212,7 @@ class Webhook { * @param {StringResolvable} content Content of the code block * @param {WebhookMessageOptions} options The options to provide * @returns {Promise} + * @deprecated */ sendCode(lang, content, options = {}) { return this.send(content, Object.assign(options, { code: lang })); @@ -187,28 +242,25 @@ class Webhook { /** * Edit the webhook. * @param {string} name The new name for the webhook - * @param {BufferResolvable} avatar The new avatar for the webhook + * @param {BufferResolvable} [avatar] The new avatar for the webhook * @returns {Promise} */ edit(name = this.name, avatar) { if (avatar) { - return this.client.resolver.resolveBuffer(avatar).then(file => { - const dataURI = this.client.resolver.resolveBase64(file); - return this.client.rest.methods.editWebhook(this, name, dataURI); - }); + return this.client.resolver.resolveImage(avatar).then(data => + this.client.rest.methods.editWebhook(this, name, data) + ); } - return this.client.rest.methods.editWebhook(this, name).then(data => { - this.setup(data); - return this; - }); + return this.client.rest.methods.editWebhook(this, name); } /** * Delete the webhook. + * @param {string} [reason] Reason for deleting the webhook * @returns {Promise} */ - delete() { - return this.client.rest.methods.deleteWebhook(this); + delete(reason) { + return this.client.rest.methods.deleteWebhook(this, reason); } } diff --git a/src/structures/interfaces/Collector.js b/src/structures/interfaces/Collector.js index 287256ebe..10a46b18e 100644 --- a/src/structures/interfaces/Collector.js +++ b/src/structures/interfaces/Collector.js @@ -5,7 +5,8 @@ const EventEmitter = require('events').EventEmitter; * Filter to be applied to the collector. * @typedef {Function} CollectorFilter * @param {...*} args Any arguments received by the listener - * @returns {boolean} To collect or not collect + * @param {Collection} collection The items collected by this collector + * @returns {boolean} */ /** @@ -78,7 +79,7 @@ class Collector extends EventEmitter { */ _handle(...args) { const collect = this.handle(...args); - if (!collect || !this.filter(...args)) return; + if (!collect || !this.filter(...args, this.collected)) return; this.collected.set(collect.key, collect.value); diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js index f7fde7319..37c106a25 100644 --- a/src/structures/interfaces/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -2,6 +2,8 @@ const path = require('path'); const Message = require('../Message'); const MessageCollector = require('../MessageCollector'); const Collection = require('../../util/Collection'); +const Attachment = require('../../structures/Attachment'); +const RichEmbed = require('../../structures/RichEmbed'); const util = require('util'); /** @@ -38,8 +40,8 @@ class TextBasedChannel { * (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|string} [file] A file to send with the message **(deprecated)** - * @property {FileOptions[]|string[]} [files] Files to send with the message + * @property {FileOptions|BufferResolvable|Attachment} [file] A file to send with the message **(deprecated)** + * @property {FileOptions[]|BufferResolvable[]|Attachment[]} [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 * it exceeds the character limit. If an object is provided, these are the options for splitting the message @@ -64,13 +66,14 @@ class TextBasedChannel { /** * Send a message to this channel. * @param {StringResolvable} [content] Text for the message - * @param {MessageOptions} [options={}] Options for the message + * @param {MessageOptions|Attachment|RichEmbed} [options] Options for the message, + * can also be just a RichEmbed or Attachment * @returns {Promise} * @example * // Send a message * channel.send('hello!') - * .then(message => console.log(`Sent message: ${message.content}`)) - * .catch(console.error); + * .then(message => console.log(`Sent message: ${message.content}`)) + * .catch(console.error); */ send(content, options) { if (!options && typeof content === 'object' && !(content instanceof Array)) { @@ -80,7 +83,13 @@ class TextBasedChannel { options = {}; } - if (options.embed && options.embed.file) options.file = options.embed.file; + if (options instanceof Attachment) options = { files: [options.file] }; + if (options instanceof RichEmbed) options = { embed: options }; + + if (options.embed && options.embed.file) { + if (options.files) options.files.push(options.embed.file); + else options.files = [options.embed.file]; + } if (options.file) { if (options.files) options.files.push(options.file); @@ -88,24 +97,28 @@ class TextBasedChannel { } if (options.files) { - for (const i in options.files) { + for (let i = 0; i < options.files.length; i++) { let file = options.files[i]; - if (typeof file === 'string') file = { attachment: file }; + if (typeof file === 'string' || Buffer.isBuffer(file)) file = { attachment: file }; if (!file.name) { if (typeof file.attachment === 'string') { file.name = path.basename(file.attachment); } else if (file.attachment && file.attachment.path) { file.name = path.basename(file.attachment.path); + } else if (file instanceof Attachment) { + file = { attachment: file.file, name: path.basename(file.file) || 'file.jpg' }; } else { file.name = 'file.jpg'; } + } else if (file instanceof Attachment) { + file = file.file; } options.files[i] = file; } return Promise.all(options.files.map(file => - this.client.resolver.resolveBuffer(file.attachment).then(buffer => { - file.file = buffer; + this.client.resolver.resolveFile(file.attachment).then(resource => { + file.file = resource; return file; }) )).then(files => this.client.rest.methods.sendMessage(this, content, options, files)); @@ -158,8 +171,8 @@ class TextBasedChannel { * @example * // Get messages * channel.fetchMessages({limit: 10}) - * .then(messages => console.log(`Received ${messages.size} messages`)) - * .catch(console.error); + * .then(messages => console.log(`Received ${messages.size} messages`)) + * .catch(console.error); */ fetchMessages(options = {}) { return this.client.rest.methods.getChannelMessages(this, options).then(data => { @@ -214,15 +227,21 @@ class TextBasedChannel { * @property {Date} [before] Date to find messages before * @property {Date} [after] Date to find messages before * @property {Date} [during] Date to find messages during (range of date to date + 24 hours) + * @property {boolean} [nsfw=false] Include results from NSFW channels + */ + + /** + * @typedef {Object} MessageSearchResult + * @property {number} totalResults Total result count + * @property {Message[][]} messages Array of message results + * The message which has triggered the result will have the `hit` property set to `true` */ /** * Performs a search within the channel. * This is only available when using a user account. * @param {MessageSearchOptions} [options={}] Options to pass to the search - * @returns {Promise>} - * An array containing arrays of messages. Each inner array is a search context cluster - * The message which has triggered the result will have the `hit` property set to `true` + * @returns {Promise} * @example * channel.search({ * content: 'discord.js', @@ -319,11 +338,11 @@ class TextBasedChannel { * @returns {MessageCollector} * @example * // Create a message collector - * const collector = channel.createCollector( - * m => m.content.includes('discord'), - * { time: 15000 } + * const collector = channel.createMessageCollector( + * m => m.content.includes('discord'), + * { time: 15000 } * ); - * collector.on('message', m => console.log(`Collected ${m.content}`)); + * collector.on('collect', m => console.log(`Collected ${m.content}`)); * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); */ createMessageCollector(filter, options = {}) { @@ -347,8 +366,8 @@ class TextBasedChannel { * const filter = m => m.content.startsWith('!vote'); * // Errors: ['time'] treats ending because of the time limit as an error * channel.awaitMessages(filter, { max: 4, time: 60000, errors: ['time'] }) - * .then(collected => console.log(collected.size)) - * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); + * .then(collected => console.log(collected.size)) + * .catch(collected => console.log(`After a minute, only ${collected.size} out of 4 voted.`)); */ awaitMessages(filter, options = {}) { return new Promise((resolve, reject) => { diff --git a/src/util/Collection.js b/src/util/Collection.js index 620d4565d..b95af8562 100644 --- a/src/util/Collection.js +++ b/src/util/Collection.js @@ -59,59 +59,99 @@ class Collection extends Map { } /** - * Obtains the first item in this collection. - * @returns {*} + * Obtains the first value(s) in this collection. + * @param {number} [count] Number of values to obtain from the beginning + * @returns {*|Array<*>} The single value if `count` is undefined, or an array of values of `count` length */ - first() { - return this.values().next().value; + first(count) { + if (count === undefined) return this.values().next().value; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + count = Math.min(this.size, count); + const arr = new Array(count); + const iter = this.values(); + for (let i = 0; i < count; i++) arr[i] = iter.next().value; + return arr; } /** - * Obtains the first key in this collection. - * @returns {*} + * Obtains the first key(s) in this collection. + * @param {number} [count] Number of keys to obtain from the beginning + * @returns {*|Array<*>} The single key if `count` is undefined, or an array of keys of `count` length */ - firstKey() { - return this.keys().next().value; + firstKey(count) { + if (count === undefined) return this.keys().next().value; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + count = Math.min(this.size, count); + const arr = new Array(count); + const iter = this.iter(); + for (let i = 0; i < count; i++) arr[i] = iter.next().value; + return arr; } /** - * Obtains the last item in this collection. This relies on the `array()` method, and thus the caching mechanism - * applies here as well. - * @returns {*} + * Obtains the last value(s) in this collection. This relies on {@link Collection#array}, and thus the caching + * mechanism applies here as well. + * @param {number} [count] Number of values to obtain from the end + * @returns {*|Array<*>} The single value if `count` is undefined, or an array of values of `count` length */ - last() { + last(count) { const arr = this.array(); - return arr[arr.length - 1]; + if (count === undefined) return arr[arr.length - 1]; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + return arr.slice(-count); } /** - * Obtains the last key in this collection. This relies on the `keyArray()` method, and thus the caching mechanism - * applies here as well. - * @returns {*} + * Obtains the last key(s) in this collection. This relies on {@link Collection#keyArray}, and thus the caching + * mechanism applies here as well. + * @param {number} [count] Number of keys to obtain from the end + * @returns {*|Array<*>} The single key if `count` is undefined, or an array of keys of `count` length */ - lastKey() { + lastKey(count) { const arr = this.keyArray(); - return arr[arr.length - 1]; + if (count === undefined) return arr[arr.length - 1]; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + return arr.slice(-count); } /** - * Obtains a random item from this collection. This relies on the `array()` method, and thus the caching mechanism - * applies here as well. - * @returns {*} + * Obtains random value(s) from this collection. This relies on {@link Collection#array}, and thus the caching + * mechanism applies here as well. + * @param {number} [count] Number of values to obtain randomly + * @returns {*|Array<*>} The single value if `count` is undefined, or an array of values of `count` length */ - random() { - const arr = this.array(); - return arr[Math.floor(Math.random() * arr.length)]; + random(count) { + let arr = this.array(); + if (count === undefined) return arr[Math.floor(Math.random() * arr.length)]; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + if (arr.length === 0) return []; + const rand = new Array(count); + arr = arr.slice(); + for (let i = 0; i < count; i++) rand[i] = arr.splice(Math.floor(Math.random() * arr.length), 1)[0]; + return rand; } /** - * Obtains a random key from this collection. This relies on the `keyArray()` method, and thus the caching mechanism - * applies here as well. - * @returns {*} + * Obtains random key(s) from this collection. This relies on {@link Collection#keyArray}, and thus the caching + * mechanism applies here as well. + * @param {number} [count] Number of keys to obtain randomly + * @returns {*|Array<*>} The single key if `count` is undefined, or an array of keys of `count` length */ - randomKey() { - const arr = this.keyArray(); - return arr[Math.floor(Math.random() * arr.length)]; + randomKey(count) { + let arr = this.keyArray(); + if (count === undefined) return arr[Math.floor(Math.random() * arr.length)]; + if (typeof count !== 'number') throw new TypeError('The count must be a number.'); + if (!Number.isInteger(count) || count < 1) throw new RangeError('The count must be an integer greater than 0.'); + if (arr.length === 0) return []; + const rand = new Array(count); + arr = arr.slice(); + for (let i = 0; i < count; i++) rand[i] = arr.splice(Math.floor(Math.random() * arr.length), 1)[0]; + return rand; } /** diff --git a/src/util/Constants.js b/src/util/Constants.js index f8895952b..36b15c61c 100644 --- a/src/util/Constants.js +++ b/src/util/Constants.js @@ -29,6 +29,7 @@ exports.Package = require('../../package.json'); * 100% certain you don't need, as many are important, but not obviously so. The safest one to disable with the * most impact is typically `TYPING_START`. * @property {WebsocketOptions} [ws] Options for the WebSocket + * @property {HTTPOptions} [http] HTTP options */ exports.DefaultOptions = { apiRequestMethod: 'sequential', @@ -63,6 +64,15 @@ exports.DefaultOptions = { }, version: 6, }, + + /** + * HTTP options + * @typedef {Object} HTTPOptions + * @property {number} [version=7] API version to use + * @property {string} [api='https://discordapp.com/api'] Base url of the API + * @property {string} [cdn='https://cdn.discordapp.com'] Base url of the CDN + * @property {string} [invite='https://discord.gg'] Base url of invites + */ http: { version: 7, host: 'https://discordapp.com', @@ -102,7 +112,10 @@ const Endpoints = exports.Endpoints = { relationships: `${base}/relationships`, settings: `${base}/settings`, Relationship: uID => `${base}/relationships/${uID}`, - Guild: guildID => `${base}/guilds/${guildID}`, + Guild: guildID => ({ + toString: () => `${base}/guilds/${guildID}`, + settings: `${base}/guilds/${guildID}/settings`, + }), Note: id => `${base}/notes/${id}`, Mentions: (limit, roles, everyone, guildID) => `${base}/mentions?limit=${limit}&roles=${roles}&everyone=${everyone}${guildID ? `&guild_id=${guildID}` : ''}`, @@ -133,7 +146,7 @@ const Endpoints = exports.Endpoints = { ack: `${base}/ack`, settings: `${base}/settings`, auditLogs: `${base}/audit-logs`, - Emoji: emojiID => Endpoints.CDN(root).Emoji(emojiID), + Emoji: emojiID => `${base}/emojis/${emojiID}`, Icon: (root, hash) => Endpoints.CDN(root).Icon(guildID, hash), Splash: (root, hash) => Endpoints.CDN(root).Splash(guildID, hash), Role: roleID => `${base}/roles/${roleID}`, @@ -164,6 +177,7 @@ const Endpoints = exports.Endpoints = { webhooks: `${base}/webhooks`, search: `${base}/messages/search`, pins: `${base}/pins`, + Icon: (root, hash) => Endpoints.CDN(root).GDMIcon(channelID, hash), Pin: messageID => `${base}/pins/${messageID}`, Recipient: recipientID => `${base}/recipients/${recipientID}`, Message: messageID => { @@ -192,6 +206,7 @@ const Endpoints = exports.Endpoints = { Asset: name => `${root}/assets/${name}`, Avatar: (userID, hash) => `${root}/avatars/${userID}/${hash}.${hash.startsWith('a_') ? 'gif' : 'png'}?size=2048`, Icon: (guildID, hash) => `${root}/icons/${guildID}/${hash}.jpg`, + GDMIcon: (channelID, hash) => `${root}/channel-icons/${channelID}/${hash}.jpg?size=2048`, Splash: (guildID, hash) => `${root}/splashes/${guildID}/${hash}.jpg`, }; }, @@ -200,7 +215,8 @@ const Endpoints = exports.Endpoints = { const base = `/oauth2/applications/${appID}`; return { toString: () => base, - reset: `${base}/reset`, + resetSecret: `${base}/reset`, + resetToken: `${base}/bot/reset`, }; }, App: appID => `/oauth2/authorize?client_id=${appID}`, @@ -212,7 +228,7 @@ const Endpoints = exports.Endpoints = { toString: () => '/gateway', bot: '/gateway/bot', }, - Invite: inviteID => `/invite/${inviteID}`, + Invite: inviteID => `/invite/${inviteID}?with_counts=true`, inviteLink: id => `https://discord.gg/${id}`, Webhook: (webhookID, token) => `/webhooks/${webhookID}${token ? `/${token}` : ''}`, }; @@ -220,12 +236,12 @@ const Endpoints = exports.Endpoints = { /** * The current status of the client. Here are the available statuses: - * - READY - * - CONNECTING - * - RECONNECTING - * - IDLE - * - NEARLY - * - DISCONNECTED + * * READY + * * CONNECTING + * * RECONNECTING + * * IDLE + * * NEARLY + * * DISCONNECTED * @typedef {number} Status */ exports.Status = { @@ -239,11 +255,11 @@ exports.Status = { /** * The current status of a voice connection. Here are the available statuses: - * - CONNECTED - * - CONNECTING - * - AUTHENTICATING - * - RECONNECTING - * - DISCONNECTED + * * CONNECTED + * * CONNECTING + * * AUTHENTICATING + * * RECONNECTING + * * DISCONNECTED * @typedef {number} VoiceStatus */ exports.VoiceStatus = { @@ -287,6 +303,7 @@ exports.VoiceOPCodes = { exports.Events = { READY: 'ready', + RESUME: 'resume', GUILD_CREATE: 'guildCreate', GUILD_DELETE: 'guildDelete', GUILD_UPDATE: 'guildUpdate', @@ -320,6 +337,7 @@ exports.Events = { USER_UPDATE: 'userUpdate', USER_NOTE_UPDATE: 'userNoteUpdate', USER_SETTINGS_UPDATE: 'clientUserSettingsUpdate', + USER_GUILD_SETTINGS_UPDATE: 'clientUserGuildSettingsUpdate', PRESENCE_UPDATE: 'presenceUpdate', VOICE_STATE_UPDATE: 'voiceStateUpdate', TYPING_START: 'typingStart', @@ -333,41 +351,41 @@ exports.Events = { /** * The type of a websocket message event, e.g. `MESSAGE_CREATE`. Here are the available events: - * - READY - * - RESUMED - * - GUILD_SYNC - * - GUILD_CREATE - * - GUILD_DELETE - * - GUILD_UPDATE - * - GUILD_MEMBER_ADD - * - GUILD_MEMBER_REMOVE - * - GUILD_MEMBER_UPDATE - * - GUILD_MEMBERS_CHUNK - * - GUILD_ROLE_CREATE - * - GUILD_ROLE_DELETE - * - GUILD_ROLE_UPDATE - * - GUILD_BAN_ADD - * - GUILD_BAN_REMOVE - * - CHANNEL_CREATE - * - CHANNEL_DELETE - * - CHANNEL_UPDATE - * - CHANNEL_PINS_UPDATE - * - MESSAGE_CREATE - * - MESSAGE_DELETE - * - MESSAGE_UPDATE - * - MESSAGE_DELETE_BULK - * - MESSAGE_REACTION_ADD - * - MESSAGE_REACTION_REMOVE - * - MESSAGE_REACTION_REMOVE_ALL - * - USER_UPDATE - * - USER_NOTE_UPDATE - * - USER_SETTINGS_UPDATE - * - PRESENCE_UPDATE - * - VOICE_STATE_UPDATE - * - TYPING_START - * - VOICE_SERVER_UPDATE - * - RELATIONSHIP_ADD - * - RELATIONSHIP_REMOVE + * * READY + * * RESUMED + * * GUILD_SYNC + * * GUILD_CREATE + * * GUILD_DELETE + * * GUILD_UPDATE + * * GUILD_MEMBER_ADD + * * GUILD_MEMBER_REMOVE + * * GUILD_MEMBER_UPDATE + * * GUILD_MEMBERS_CHUNK + * * GUILD_ROLE_CREATE + * * GUILD_ROLE_DELETE + * * GUILD_ROLE_UPDATE + * * GUILD_BAN_ADD + * * GUILD_BAN_REMOVE + * * CHANNEL_CREATE + * * CHANNEL_DELETE + * * CHANNEL_UPDATE + * * CHANNEL_PINS_UPDATE + * * MESSAGE_CREATE + * * MESSAGE_DELETE + * * MESSAGE_UPDATE + * * MESSAGE_DELETE_BULK + * * MESSAGE_REACTION_ADD + * * MESSAGE_REACTION_REMOVE + * * MESSAGE_REACTION_REMOVE_ALL + * * USER_UPDATE + * * USER_NOTE_UPDATE + * * USER_SETTINGS_UPDATE + * * PRESENCE_UPDATE + * * VOICE_STATE_UPDATE + * * TYPING_START + * * VOICE_SERVER_UPDATE + * * RELATIONSHIP_ADD + * * RELATIONSHIP_REMOVE * @typedef {string} WSEventType */ exports.WSEvents = { @@ -401,6 +419,7 @@ exports.WSEvents = { USER_UPDATE: 'USER_UPDATE', USER_NOTE_UPDATE: 'USER_NOTE_UPDATE', USER_SETTINGS_UPDATE: 'USER_SETTINGS_UPDATE', + USER_GUILD_SETTINGS_UPDATE: 'USER_GUILD_SETTINGS_UPDATE', PRESENCE_UPDATE: 'PRESENCE_UPDATE', VOICE_STATE_UPDATE: 'VOICE_STATE_UPDATE', TYPING_START: 'TYPING_START', @@ -409,6 +428,18 @@ exports.WSEvents = { RELATIONSHIP_REMOVE: 'RELATIONSHIP_REMOVE', }; +/** + * The type of a message, e.g. `DEFAULT`. Here are the available types: + * * DEFAULT + * * RECIPIENT_ADD + * * RECIPIENT_REMOVE + * * CALL + * * CHANNEL_NAME_CHANGE + * * CHANNEL_ICON_CHANGE + * * PINS_ADD + * * GUILD_MEMBER_JOIN + * @typedef {string} MessageType + */ exports.MessageTypes = [ 'DEFAULT', 'RECIPIENT_ADD', @@ -420,6 +451,21 @@ exports.MessageTypes = [ 'GUILD_MEMBER_JOIN', ]; +/** + * The type of a message notification setting. Here are the available types: + * * EVERYTHING + * * MENTIONS + * * NOTHING + * * INHERIT (only for GuildChannel) + * @typedef {string} MessageNotificationType + */ +exports.MessageNotificationTypes = [ + 'EVERYTHING', + 'MENTIONS', + 'NOTHING', + 'INHERIT', +]; + exports.DefaultAvatars = { BLURPLE: '6debd47ed13483642cf09e832ed0bc1b', GREY: '322c936a8c8be1b803cd94861bdfa868', @@ -543,8 +589,8 @@ exports.UserSettingsMap = { explicit_content_filter: function explicitContentFilter(type) { // eslint-disable-line func-name-matching /** - * Safe direct messaging; force people's messages with images to be scanned before they are sent to you - * one of `DISABLED`, `NON_FRIENDS`, `FRIENDS_AND_NON_FRIENDS` + * Safe direct messaging; force people's messages with images to be scanned before they are sent to you. + * One of `DISABLED`, `NON_FRIENDS`, `FRIENDS_AND_NON_FRIENDS` * @name ClientUserSettings#explicitContentFilter * @type {string} */ @@ -567,6 +613,58 @@ exports.UserSettingsMap = { }, }; +exports.UserGuildSettingsMap = { + message_notifications: function messageNotifications(type) { // eslint-disable-line func-name-matching + /** + * The type of message that should notify you + * @name ClientUserGuildSettings#messageNotifications + * @type {MessageNotificationType} + */ + return exports.MessageNotificationTypes[type]; + }, + /** + * Whether to receive mobile push notifications + * @name ClientUserGuildSettings#mobilePush + * @type {boolean} + */ + mobile_push: 'mobilePush', + /** + * Whether the guild is muted + * @name ClientUserGuildSettings#muted + * @type {boolean} + */ + muted: 'muted', + /** + * Whether to suppress everyone mention + * @name ClientUserGuildSettings#suppressEveryone + * @type {boolean} + */ + suppress_everyone: 'suppressEveryone', + /** + * A collection containing all the channel overrides + * @name ClientUserGuildSettings#channelOverrides + * @type {Collection} + */ + channel_overrides: 'channelOverrides', +}; + +exports.UserChannelOverrideMap = { + message_notifications: function messageNotifications(type) { // eslint-disable-line func-name-matching + /** + * The type of message that should notify you + * @name ClientUserChannelOverride#messageNotifications + * @type {MessageNotificationType} + */ + return exports.MessageNotificationTypes[type]; + }, + /** + * Whether the channel is muted + * @name ClientUserChannelOverride#muted + * @type {boolean} + */ + muted: 'muted', +}; + exports.Colors = { DEFAULT: 0x000000, AQUA: 0x1ABC9C, @@ -594,3 +692,96 @@ exports.Colors = { DARK_BUT_NOT_BLACK: 0x2C2F33, NOT_QUITE_BLACK: 0x23272A, }; + +/** + * An error encountered while performing an API request. Here are the potential errors: + * * UNKNOWN_ACCOUNT + * * UNKNOWN_APPLICATION + * * UNKNOWN_CHANNEL + * * UNKNOWN_GUILD + * * UNKNOWN_INTEGRATION + * * UNKNOWN_INVITE + * * UNKNOWN_MEMBER + * * UNKNOWN_MESSAGE + * * UNKNOWN_OVERWRITE + * * UNKNOWN_PROVIDER + * * UNKNOWN_ROLE + * * UNKNOWN_TOKEN + * * UNKNOWN_USER + * * UNKNOWN_EMOJI + * * BOT_PROHIBITED_ENDPOINT + * * BOT_ONLY_ENDPOINT + * * MAXIMUM_GUILDS + * * MAXIMUM_FRIENDS + * * MAXIMUM_PINS + * * MAXIMUM_ROLES + * * MAXIMUM_REACTIONS + * * UNAUTHORIZED + * * MISSING_ACCESS + * * INVALID_ACCOUNT_TYPE + * * CANNOT_EXECUTE_ON_DM + * * EMBED_DISABLED + * * CANNOT_EDIT_MESSAGE_BY_OTHER + * * CANNOT_SEND_EMPTY_MESSAGE + * * CANNOT_MESSAGE_USER + * * CANNOT_SEND_MESSAGES_IN_VOICE_CHANNEL + * * CHANNEL_VERIFICATION_LEVEL_TOO_HIGH + * * OAUTH2_APPLICATION_BOT_ABSENT + * * MAXIMUM_OAUTH2_APPLICATIONS + * * INVALID_OAUTH_STATE + * * MISSING_PERMISSIONS + * * INVALID_AUTHENTICATION_TOKEN + * * NOTE_TOO_LONG + * * INVALID_BULK_DELETE_QUANTITY + * * CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL + * * CANNOT_EXECUTE_ON_SYSTEM_MESSAGE + * * BULK_DELETE_MESSAGE_TOO_OLD + * * INVITE_ACCEPTED_TO_GUILD_NOT_CONTANING_BOT + * * REACTION_BLOCKED + * @typedef {string} APIError + */ +exports.APIErrors = { + UNKNOWN_ACCOUNT: 10001, + UNKNOWN_APPLICATION: 10002, + UNKNOWN_CHANNEL: 10003, + UNKNOWN_GUILD: 10004, + UNKNOWN_INTEGRATION: 10005, + UNKNOWN_INVITE: 10006, + UNKNOWN_MEMBER: 10007, + UNKNOWN_MESSAGE: 10008, + UNKNOWN_OVERWRITE: 10009, + UNKNOWN_PROVIDER: 10010, + UNKNOWN_ROLE: 10011, + UNKNOWN_TOKEN: 10012, + UNKNOWN_USER: 10013, + UNKNOWN_EMOJI: 10014, + BOT_PROHIBITED_ENDPOINT: 20001, + BOT_ONLY_ENDPOINT: 20002, + MAXIMUM_GUILDS: 30001, + MAXIMUM_FRIENDS: 30002, + MAXIMUM_PINS: 30003, + MAXIMUM_ROLES: 30005, + MAXIMUM_REACTIONS: 30010, + UNAUTHORIZED: 40001, + MISSING_ACCESS: 50001, + INVALID_ACCOUNT_TYPE: 50002, + CANNOT_EXECUTE_ON_DM: 50003, + EMBED_DISABLED: 50004, + CANNOT_EDIT_MESSAGE_BY_OTHER: 50005, + CANNOT_SEND_EMPTY_MESSAGE: 50006, + CANNOT_MESSAGE_USER: 50007, + CANNOT_SEND_MESSAGES_IN_VOICE_CHANNEL: 50008, + CHANNEL_VERIFICATION_LEVEL_TOO_HIGH: 50009, + OAUTH2_APPLICATION_BOT_ABSENT: 50010, + MAXIMUM_OAUTH2_APPLICATIONS: 50011, + INVALID_OAUTH_STATE: 50012, + MISSING_PERMISSIONS: 50013, + INVALID_AUTHENTICATION_TOKEN: 50014, + NOTE_TOO_LONG: 50015, + INVALID_BULK_DELETE_QUANTITY: 50016, + CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL: 50019, + CANNOT_EXECUTE_ON_SYSTEM_MESSAGE: 50021, + BULK_DELETE_MESSAGE_TOO_OLD: 50034, + INVITE_ACCEPTED_TO_GUILD_NOT_CONTANING_BOT: 50036, + REACTION_BLOCKED: 90001, +}; diff --git a/src/util/Permissions.js b/src/util/Permissions.js index d5af0c7d1..c2d604dbc 100644 --- a/src/util/Permissions.js +++ b/src/util/Permissions.js @@ -103,7 +103,7 @@ class Permissions { } /** - * Gets an object mapping permission name (like `READ_MESSAGES`) to a {@link boolean} indicating whether the + * Gets an object mapping permission name (like `VIEW_CHANNEL`) to a {@link boolean} indicating whether the * permission is available. * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override * @returns {Object} @@ -152,8 +152,8 @@ class Permissions { /** * Data that can be resolved to give a permission number. This can be: - * - A string (see {@link Permissions.flags}) - * - A permission number + * * A string (see {@link Permissions.FLAGS}) + * * A permission number * @typedef {string|number} PermissionResolvable */ @@ -180,7 +180,8 @@ class Permissions { * - `MANAGE_GUILD` (edit the guild information, region, etc.) * - `ADD_REACTIONS` (add new reactions to messages) * - `VIEW_AUDIT_LOG` - * - `READ_MESSAGES` + * - `VIEW_CHANNEL` + * - `READ_MESSAGES` **(deprecated)** * - `SEND_MESSAGES` * - `SEND_TTS_MESSAGES` * - `MANAGE_MESSAGES` (delete messages and reactions) @@ -215,6 +216,7 @@ Permissions.FLAGS = { ADD_REACTIONS: 1 << 6, VIEW_AUDIT_LOG: 1 << 7, + VIEW_CHANNEL: 1 << 10, READ_MESSAGES: 1 << 10, SEND_MESSAGES: 1 << 11, SEND_TTS_MESSAGES: 1 << 12, @@ -268,8 +270,8 @@ Permissions.prototype.missingPermissions = util.deprecate(Permissions.prototype. 'EvaluatedPermissions#missingPermissions is deprecated, use Permissions#missing instead'); Object.defineProperty(Permissions.prototype, 'member', { get: util - .deprecate(Object.getOwnPropertyDescriptor(Permissions.prototype, 'member').get, - 'EvaluatedPermissions#member is deprecated'), + .deprecate(Object.getOwnPropertyDescriptor(Permissions.prototype, 'member').get, + 'EvaluatedPermissions#member is deprecated'), }); module.exports = Permissions; diff --git a/src/util/Util.js b/src/util/Util.js index 9b7d536cd..c23051a65 100644 --- a/src/util/Util.js +++ b/src/util/Util.js @@ -66,9 +66,9 @@ class Util { /** * Parses emoji info out of a string. The string must be one of: - * - A UTF-8 emoji (no ID) - * - A URL-encoded UTF-8 emoji (no ID) - * - A Discord custom emoji (`<:name:id>`) + * * A UTF-8 emoji (no ID) + * * A URL-encoded UTF-8 emoji (no ID) + * * A Discord custom emoji (`<:name:id>`) * @param {string} text Emoji string to parse * @returns {Object} Object with `name` and `id` properties * @private diff --git a/test/voice.js b/test/voice.js index 396bcc490..65f4329e7 100644 --- a/test/voice.js +++ b/test/voice.js @@ -1,78 +1,78 @@ -/* eslint no-console: 0 */ -'use strict'; - -const Discord = require('../'); -const ytdl = require('ytdl-core'); - -const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' }); - -const auth = require('./auth.json'); - -client.login(auth.token).then(() => console.log('logged')).catch(console.error); - -const connections = new Map(); - -let broadcast; - -client.on('message', m => { - if (!m.guild) return; - if (m.content.startsWith('/join')) { - const channel = m.guild.channels.get(m.content.split(' ')[1]) || m.member.voiceChannel; - if (channel && channel.type === 'voice') { - channel.join().then(conn => { - conn.player.on('error', (...e) => console.log('player', ...e)); - if (!connections.has(m.guild.id)) connections.set(m.guild.id, { conn, queue: [] }); - m.reply('ok!'); - }); - } else { - m.reply('Specify a voice channel!'); - } - } else if (m.content.startsWith('/play')) { - if (connections.has(m.guild.id)) { - const connData = connections.get(m.guild.id); - const queue = connData.queue; - const url = m.content.split(' ').slice(1).join(' ') - .replace(//g, ''); - queue.push({ url, m }); - if (queue.length > 1) { - m.reply(`OK, that's going to play after ${queue.length - 1} songs`); - return; - } - doQueue(connData); - } - } else if (m.content.startsWith('/skip')) { - if (connections.has(m.guild.id)) { - const connData = connections.get(m.guild.id); - if (connData.dispatcher) { - connData.dispatcher.end(); - } - } - } else if (m.content.startsWith('#eval') && m.author.id === '66564597481480192') { - try { - const com = eval(m.content.split(' ').slice(1).join(' ')); - m.channel.sendMessage(`\`\`\`\n${com}\`\`\``); - } catch (e) { - console.log(e); - m.channel.sendMessage(`\`\`\`\n${e}\`\`\``); - } - } -}); - -function doQueue(connData) { - const conn = connData.conn; - const queue = connData.queue; - const item = queue[0]; - if (!item) return; - const stream = ytdl(item.url, { filter: 'audioonly' }, { passes: 3 }); - const dispatcher = conn.playStream(stream); - stream.on('info', info => { - item.m.reply(`OK, playing **${info.title}**`); - }); - dispatcher.on('end', () => { - queue.shift(); - doQueue(connData); - }); - dispatcher.on('error', (...e) => console.log('dispatcher', ...e)); - connData.dispatcher = dispatcher; -} +/* eslint no-console: 0 */ +'use strict'; + +const Discord = require('../'); +const ytdl = require('ytdl-core'); + +const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' }); + +const auth = require('./auth.json'); + +client.login(auth.token).then(() => console.log('logged')).catch(console.error); + +const connections = new Map(); + +let broadcast; + +client.on('message', m => { + if (!m.guild) return; + if (m.content.startsWith('/join')) { + const channel = m.guild.channels.get(m.content.split(' ')[1]) || m.member.voiceChannel; + if (channel && channel.type === 'voice') { + channel.join().then(conn => { + conn.player.on('error', (...e) => console.log('player', ...e)); + if (!connections.has(m.guild.id)) connections.set(m.guild.id, { conn, queue: [] }); + m.reply('ok!'); + }); + } else { + m.reply('Specify a voice channel!'); + } + } else if (m.content.startsWith('/play')) { + if (connections.has(m.guild.id)) { + const connData = connections.get(m.guild.id); + const queue = connData.queue; + const url = m.content.split(' ').slice(1).join(' ') + .replace(//g, ''); + queue.push({ url, m }); + if (queue.length > 1) { + m.reply(`OK, that's going to play after ${queue.length - 1} songs`); + return; + } + doQueue(connData); + } + } else if (m.content.startsWith('/skip')) { + if (connections.has(m.guild.id)) { + const connData = connections.get(m.guild.id); + if (connData.dispatcher) { + connData.dispatcher.end(); + } + } + } else if (m.content.startsWith('#eval') && m.author.id === '66564597481480192') { + try { + const com = eval(m.content.split(' ').slice(1).join(' ')); + m.channel.sendMessage(`\`\`\`\n${com}\`\`\``); + } catch (e) { + console.log(e); + m.channel.sendMessage(`\`\`\`\n${e}\`\`\``); + } + } +}); + +function doQueue(connData) { + const conn = connData.conn; + const queue = connData.queue; + const item = queue[0]; + if (!item) return; + const stream = ytdl(item.url, { filter: 'audioonly' }, { passes: 3 }); + const dispatcher = conn.playStream(stream); + stream.on('info', info => { + item.m.reply(`OK, playing **${info.title}**`); + }); + dispatcher.on('end', () => { + queue.shift(); + doQueue(connData); + }); + dispatcher.on('error', (...e) => console.log('dispatcher', ...e)); + connData.dispatcher = dispatcher; +} diff --git a/typings b/typings index b500eb233..697fc933d 160000 --- a/typings +++ b/typings @@ -1 +1 @@ -Subproject commit b500eb233182a3c7a5655ae29423844e82e72ab7 +Subproject commit 697fc933de90209b81b69bd0fe87883e3c7a217d diff --git a/webpack.config.js b/webpack.config.js index 73e6c43ec..db75a5e23 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,19 +5,26 @@ const webpack = require('webpack'); const createVariants = require('parallel-webpack').createVariants; +const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); const version = require('./package.json').version; const createConfig = options => { const plugins = [ new webpack.DefinePlugin({ 'global.GENTLY': false }), + new webpack.optimize.ModuleConcatenationPlugin(), + new webpack.DefinePlugin({ + 'process.env': { + __DISCORD_WEBPACK__: '"true"', + }, + }), ]; - if (options.minify) plugins.push(new webpack.optimize.UglifyJsPlugin({ minimize: true })); + if (options.minify) plugins.push(new UglifyJSPlugin({ uglifyOptions: { output: { comments: false } } })); const filename = `./webpack/discord${process.env.VERSIONED === 'false' ? '' : '.' + version}${options.minify ? '.min' : ''}.js`; // eslint-disable-line return { - entry: './src/index.js', + entry: './browser.js', output: { path: __dirname, filename,