diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 69e3725fe..9f84b1073 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -14,4 +14,4 @@ To get ready to work on the codebase, please do the following:
3. If you're working on voice, also run `npm install node-opus` or `npm install opusscript`
4. Code your heart out!
5. Run `npm test` to run ESLint and ensure any JSDoc changes are valid
-6. [Submit a pull request](https://github.com/hydrabolt/discord.js/compare)
+6. [Submit a pull request](https://github.com/discordjs/discord.js/compare)
diff --git a/.gitmodules b/.gitmodules
index d5aa0ecce..44fff6d5f 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,3 @@
[submodule "typings"]
path = typings
- url = https://github.com/zajrik/discord.js-typings
+ url = https://github.com/discordjs/discord.js-typings
diff --git a/LICENSE b/LICENSE
index 90cf11032..5f32c7958 100644
--- a/LICENSE
+++ b/LICENSE
@@ -175,7 +175,7 @@
END OF TERMS AND CONDITIONS
- Copyright 2017 Amish Shah
+ Copyright 2015 - 2018 Amish Shah
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
diff --git a/README.md b/README.md
index 989d2d652..6677c38ae 100644
--- a/README.md
+++ b/README.md
@@ -8,12 +8,12 @@
-
-
+
+
-
+
@@ -30,9 +30,9 @@ discord.js is a powerful [node.js](https://nodejs.org) module that allows you to
**Node.js 8.0.0 or newer is required.**
Ignore any warnings about unmet peer dependencies, as they're all optional.
-Without voice support: `npm i discord.js`
-With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm i discord.js node-opus`
-With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm i discord.js opusscript`
+Without voice support: `npm install discord.js`
+With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm install discord.js node-opus`
+With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm install discord.js opusscript`
### Audio engines
The preferred audio engine is node-opus, as it performs significantly better than opusscript. When both are available, discord.js will automatically choose node-opus.
@@ -40,13 +40,13 @@ Using opusscript is only recommended for development environments where node-opu
For production bots, using node-opus should be considered a necessity, especially if they're going to be running on multiple servers.
### Optional packages
-- [zlib-sync](https://www.npmjs.com/package/zlib-sync) for significantly faster WebSocket data inflation (`npm i zlib-sync`)
-- [erlpack](https://github.com/discordapp/erlpack) for significantly faster WebSocket data (de)serialisation (`npm i discordapp/erlpack`)
+- [zlib-sync](https://www.npmjs.com/package/zlib-sync) for significantly faster WebSocket data inflation (`npm install zlib-sync`)
+- [erlpack](https://github.com/discordapp/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install discordapp/erlpack`)
- One of the following packages can be installed for faster voice packet encryption and decryption:
- - [sodium](https://www.npmjs.com/package/sodium) (`npm i sodium`)
- - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm i libsodium-wrappers`)
-- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm i uws`)
-- [bufferutil](https://www.npmjs.com/package/bufferutil) for a much faster WebSocket connection when *not* using uws (`npm i bufferutil`)
+ - [sodium](https://www.npmjs.com/package/sodium) (`npm install sodium`)
+ - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm install libsodium-wrappers`)
+- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws`)
+- [bufferutil](https://www.npmjs.com/package/bufferutil) for a much faster WebSocket connection when *not* using uws (`npm install bufferutil`)
## Example usage
```js
@@ -54,34 +54,34 @@ const Discord = require('discord.js');
const client = new Discord.Client();
client.on('ready', () => {
- console.log('I am ready!');
+ console.log(`Logged in as ${client.user.tag}!`);
});
-client.on('message', message => {
- if (message.content === 'ping') {
- message.reply('pong');
+client.on('message', msg => {
+ if (msg.content === 'ping') {
+ msg.reply('pong');
}
});
-client.login('your token');
+client.login('token');
```
## Links
-* [Website](https://discord.js.org/) ([source](https://github.com/hydrabolt/discord.js-site))
+* [Website](https://discord.js.org/) ([source](https://github.com/discordjs/website))
* [Documentation](https://discord.js.org/#/docs)
* [Discord.js Discord server](https://discord.gg/bRCvFy9)
* [Discord API Discord server](https://discord.gg/discord-api)
-* [GitHub](https://github.com/hydrabolt/discord.js)
+* [GitHub](https://github.com/discordjs/discord.js)
* [NPM](https://www.npmjs.com/package/discord.js)
* [Related libraries](https://discordapi.com/unofficial/libs.html)
### Extensions
-* [discord-rpc](https://www.npmjs.com/package/discord-rpc) ([github](https://github.com/devsnek/discord-rpc))
+* [RPC](https://www.npmjs.com/package/discord-rpc) ([source](https://github.com/discordjs/RPC))
## 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/.github/CONTRIBUTING.md) if you'd like to submit a PR.
+See [the contribution guide](https://github.com/discordjs/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/general/updating.md b/docs/general/updating.md
index cc2c7399f..0eab3f4b9 100644
--- a/docs/general/updating.md
+++ b/docs/general/updating.md
@@ -1,11 +1,11 @@
# 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
+See [the changelog](https://github.com/discordjs/discord.js/releases/tag/11.1.0) for a full list of changes, including
information about deprecations.
# Version 11
Version 11 contains loads of new and improved features, optimisations, and bug fixes.
-See [the changelog](https://github.com/hydrabolt/discord.js/releases/tag/11.0.0) for a full list of changes.
+See [the changelog](https://github.com/discordjs/discord.js/releases/tag/11.0.0) for a full list of changes.
## Significant additions
* Message Reactions and Embeds (rich text)
diff --git a/docs/general/welcome.md b/docs/general/welcome.md
index 84b06ed33..ace4a20a9 100644
--- a/docs/general/welcome.md
+++ b/docs/general/welcome.md
@@ -8,8 +8,8 @@
-
-
+
+
@@ -17,7 +17,10 @@
# Welcome!
-Welcome to the discord.js v12.0.0 documentation.
+Welcome to the discord.js v12 documentation.
+
+v12 is still very much a work-in-progress, as we're aiming to make it the best it can possibly be before releasing.
+Only use it if you are fond of living life on the bleeding edge.
## About
discord.js is a powerful [node.js](https://nodejs.org) module that allows you to interact with the
@@ -32,9 +35,9 @@ discord.js is a powerful [node.js](https://nodejs.org) module that allows you to
**Node.js 8.0.0 or newer is required.**
Ignore any warnings about unmet peer dependencies, as they're all optional.
-Without voice support: `npm install discord.js --save`
-With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm install discord.js node-opus --save`
-With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm install discord.js opusscript --save`
+Without voice support: `npm install discord.js`
+With voice support ([node-opus](https://www.npmjs.com/package/node-opus)): `npm install discord.js node-opus`
+With voice support ([opusscript](https://www.npmjs.com/package/opusscript)): `npm install discord.js opusscript`
### Audio engines
The preferred audio engine is node-opus, as it performs significantly better than opusscript. When both are available, discord.js will automatically choose node-opus.
@@ -42,12 +45,13 @@ Using opusscript is only recommended for development environments where node-opu
For production bots, using node-opus should be considered a necessity, especially if they're going to be running on multiple servers.
### Optional packages
-- [bufferutil](https://www.npmjs.com/package/bufferutil) to greatly speed up the WebSocket when *not* using uws (`npm install bufferutil --save`)
-- [erlpack](https://github.com/hammerandchisel/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install discordapp/erlpack --save`)
+- [zlib-sync](https://www.npmjs.com/package/zlib-sync) for significantly faster WebSocket data inflation (`npm install zlib-sync`)
+- [erlpack](https://github.com/discordapp/erlpack) for significantly faster WebSocket data (de)serialisation (`npm install discordapp/erlpack`)
- One of the following packages can be installed for faster voice packet encryption and decryption:
- - [sodium](https://www.npmjs.com/package/sodium) (`npm install sodium --save`)
- - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm install libsodium-wrappers --save`)
-- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws --save`)
+ - [sodium](https://www.npmjs.com/package/sodium) (`npm install sodium`)
+ - [libsodium.js](https://www.npmjs.com/package/libsodium-wrappers) (`npm install libsodium-wrappers`)
+- [uws](https://www.npmjs.com/package/uws) for a much faster WebSocket connection (`npm install uws`)
+- [bufferutil](https://www.npmjs.com/package/bufferutil) for a much faster WebSocket connection when *not* using uws (`npm install bufferutil`)
## Example usage
```js
@@ -55,31 +59,34 @@ const Discord = require('discord.js');
const client = new Discord.Client();
client.on('ready', () => {
- console.log('I am ready!');
+ console.log(`Logged in as ${client.user.tag}!`);
});
-client.on('message', message => {
- if (message.content === 'ping') {
- message.reply('pong');
+client.on('message', msg => {
+ if (msg.content === 'ping') {
+ msg.reply('pong');
}
});
-client.login('your token');
+client.login('token');
```
## Links
-* [Website](https://discord.js.org/) ([source](https://github.com/hydrabolt/discord.js-site))
+* [Website](https://discord.js.org/) ([source](https://github.com/discordjs/website))
* [Documentation](https://discord.js.org/#/docs)
-* [Discord.js server](https://discord.gg/bRCvFy9)
-* [Discord API server](https://discord.gg/rV4BwdK)
-* [GitHub](https://github.com/hydrabolt/discord.js)
+* [Discord.js Discord server](https://discord.gg/bRCvFy9)
+* [Discord API Discord server](https://discord.gg/discord-api)
+* [GitHub](https://github.com/discordjs/discord.js)
* [NPM](https://www.npmjs.com/package/discord.js)
-* [Related libraries](https://discordapi.com/unofficial/libs.html) (see also [discord-rpc](https://www.npmjs.com/package/discord-rpc))
+* [Related libraries](https://discordapi.com/unofficial/libs.html)
+
+### Extensions
+* [RPC](https://www.npmjs.com/package/discord-rpc) ([source](https://github.com/discordjs/RPC))
## 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/.github/CONTRIBUTING.md) if you'd like to submit a PR.
+See [the contribution guide](https://github.com/discordjs/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 c7e88d04b..0bfe45054 100644
--- a/docs/topics/voice.md
+++ b/docs/topics/voice.md
@@ -6,8 +6,8 @@ In discord.js, you can use voice by connecting to a `VoiceChannel` to obtain a `
To get started, make sure you have:
* ffmpeg - `npm install ffmpeg-binaries`
* an opus encoder, choose one from below:
+ * `npm install node-opus` (better performance)
* `npm install opusscript`
- * `npm install node-opus`
* a good network connection
## Joining a voice channel
@@ -20,7 +20,7 @@ const client = new Discord.Client();
client.login('token here');
-client.on('message', message => {
+client.on('message', async message => {
// Voice only works in guilds, if the message does not come from a guild,
// we ignore it
if (!message.guild) return;
@@ -28,11 +28,7 @@ client.on('message', message => {
if (message.content === '/join') {
// Only try to join the sender's voice channel if they are in one themselves
if (message.member.voiceChannel) {
- message.member.voiceChannel.join()
- .then(connection => { // Connection is an instance of VoiceConnection
- message.reply('I have successfully connected to the channel!');
- })
- .catch(console.log);
+ const connection = await message.member.voiceChannel.join();
} else {
message.reply('You need to join a voice channel first!');
}
@@ -42,73 +38,98 @@ client.on('message', message => {
## Streaming to a Voice Channel
In the previous example, we looked at how to join a voice channel in order to obtain a `VoiceConnection`. Now that we
-have obtained a voice connection, we can start streaming audio to it. The following example shows how to stream an mp3
-file:
+have obtained a voice connection, we can start streaming audio to it.
-**Playing a file:**
+### Introduction to playing on voice connections
+The most basic example of playing audio over a connection would be playing a local file:
```js
-// Use an absolute path
-const dispatcher = connection.playFile('C:/Users/Discord/Desktop/myfile.mp3');
+const dispatcher = connection.play('/home/discord/audio.mp3');
```
-```js
-// Or an dynamic path
-const dispatcher = connection.playFile('./myfile.mp3');
-```
-
-Your file doesn't have to be just an mp3; ffmpeg can convert videos and audios of many formats.
-
-The `dispatcher` variable is an instance of a `StreamDispatcher`, which manages streaming a specific resource to a voice
-channel. We can do many things with the dispatcher, such as finding out when the stream ends or changing the volume:
+The `dispatcher` in this case is a `StreamDispatcher` - here you can control the volume and playback of the stream:
```js
-dispatcher.on('end', () => {
- // The song has finished
+dispatcher.pause();
+dispatcher.resume();
+
+dispatcher.setVolume(0.5); // half the volume
+
+dispatcher.on('finish', () => {
+ console.log('Finished playing!');
});
-dispatcher.on('error', e => {
- // Catch any errors that may arise
- console.log(e);
+dispatcher.destroy(); // end the stream
+```
+
+We can also pass in options when we first play the stream:
+
+```js
+const dispatcher = connection.play('/home/discord/audio.mp3', {
+ volume: 0.5,
+ passes: 3
+});
+```
+
+These are just a subset of the options available (consult documentation for a full list). Most users may be interested in the `passes` option, however. As audio is sent over UDP, there is a chance packets may not arrive. Increasing the number of passes, e.g. to `3` gives you a better chance that your packets reach your recipients, at the cost of triple the bandwidth. We recommend not going over 5 passes.
+
+### What can I play?
+
+Discord.js allows you to play a lot of things:
+
+```js
+// ReadableStreams, in this example YouTube audio
+const ytdl = require('ytdl-core');
+connection.play(ytdl(
+ 'https://www.youtube.com/watch?v=ZlAU_w7-Xp8',
+ { filter: 'audioonly' }));
+
+// Files on the internet
+connection.play('http://www.sample-videos.com/audio/mp3/wave.mp3');
+
+// Local files
+connection.play('/home/discord/audio.mp3');
+```
+
+New to v12 is the ability to play OggOpus and WebmOpus streams with much better performance by skipping out Ffmpeg. Note this comes at the cost of no longer having volume control over the stream:
+
+```js
+connection.play(fs.createReadStream('./media.webm'), {
+ type: 'webm/opus'
});
-dispatcher.setVolume(0.5); // Set the volume to 50%
-dispatcher.setVolume(1); // Set the volume back to 100%
-
-console.log(dispatcher.time); // The time in milliseconds that the stream dispatcher has been playing for
-
-dispatcher.pause(); // Pause the stream
-dispatcher.resume(); // Carry on playing
-
-dispatcher.end(); // End the dispatcher, emits 'end' event
+connection.play(fs.createReadStream('./media.ogg'), {
+ type: 'ogg/opus'
+});
```
-If you have an existing [ReadableStream](https://nodejs.org/api/stream.html#stream_readable_streams),
-this can also be used:
+Make sure to consult the documentation for a full list of what you can play - there's too much to cover here!
-**Playing a ReadableStream:**
-```js
-connection.playStream(myReadableStream);
+## Voice Broadcasts
-// You can use fs.createReadStream to create an ReadableStream
-
-const fs = require('fs');
-const stream = fs.createReadStream('./test.mp3');
-connection.playStream(stream);
-```
-
-It's important to note that creating a readable stream to a file is less efficient than simply using `connection.playFile()`.
-
-**Playing anything else:**
-
-For anything else, such as a URL to a file, you can use `connection.playArbitraryInput()`. You should consult the [ffmpeg protocol documentation](https://ffmpeg.org/ffmpeg-protocols.html) to see what you can use this for.
+A voice broadcast is very useful for "radio" bots, that play the same audio across multiple channels. It means audio is only transcoded once, and is much better on performance.
```js
-// Play an mp3 from a URL
-connection.playArbitraryInput('http://mysite.com/sound.mp3');
+const broadcast = client.createVoiceBroadcast();
+
+broadcast.on('subscribe', dispatcher => {
+ console.log('New broadcast subscriber!');
+});
+
+broadcast.on('unsubscribe', dispatcher => {
+ console.log('Channel unsubscribed from broadcast :(');
+})
```
-Again, playing a file from a URL like this is more performant than creating a ReadableStream to the file.
+`broadcast` is an instance of `VoiceBroadcast`, which has the same `play` method you are used to with regular VoiceConnections:
-## Advanced Topics
-soon:tm:
+```js
+const dispatcher = broadcast.play('./audio.mp3');
+
+connection.play(broadcast);
+```
+
+It's important to note that the `dispatcher` stored above is a `BroadcastDispatcher` - it controls all the dispatcher subscribed to the broadcast, e.g. setting the volume of this dispatcher affects the volume of all subscribers.
+
+## Voice Receive
+coming soonβ’
diff --git a/docs/topics/web.md b/docs/topics/web.md
index 660651bb7..863051212 100644
--- a/docs/topics/web.md
+++ b/docs/topics/web.md
@@ -17,7 +17,7 @@ const Discord = require('discord.js/browser');
```
### Webpack File
-You can obtain your desired version of discord.js' web build from the [webpack branch](https://github.com/hydrabolt/discord.js/tree/webpack) of the GitHub repository.
+You can obtain your desired version of discord.js' web build from the [webpack branch](https://github.com/discordjs/discord.js/tree/webpack) of the GitHub repository.
There is a file for each branch and version of the library, and the ones ending in `.min.js` are minified to substantially reduce the size of the source code.
Include the file on the page just as you would any other JS library, like so:
diff --git a/package.json b/package.json
index fd2b846c8..0eb2aa7a8 100644
--- a/package.json
+++ b/package.json
@@ -6,8 +6,8 @@
"types": "./typings/index.d.ts",
"scripts": {
"test": "npm run lint && npm run docs:test",
- "docs": "docgen --source src --custom docs/index.yml --output docs/docs.json --jsdoc jsdoc.json",
- "docs:test": "docgen --source src --custom docs/index.yml --jsdoc jsdoc.json",
+ "docs": "docgen --source src --custom docs/index.yml --output docs/docs.json",
+ "docs:test": "docgen --source src --custom docs/index.yml",
"lint": "eslint src *.js",
"lint:fix": "eslint --fix src",
"build:browser": "webpack",
@@ -15,7 +15,7 @@
},
"repository": {
"type": "git",
- "url": "git+https://github.com/hydrabolt/discord.js.git"
+ "url": "git+https://github.com/discordjs/discord.js.git"
},
"keywords": [
"discord",
@@ -28,36 +28,33 @@
"author": "Amish Shah ",
"license": "Apache-2.0",
"bugs": {
- "url": "https://github.com/hydrabolt/discord.js/issues"
+ "url": "https://github.com/discordjs/discord.js/issues"
},
- "homepage": "https://github.com/hydrabolt/discord.js#readme",
+ "homepage": "https://github.com/discordjs/discord.js#readme",
"runkitExampleFilename": "./docs/examples/ping.js",
"unpkg": "./webpack/discord.min.js",
"dependencies": {
"pako": "^1.0.0",
- "prism-media": "^0.0.2",
- "snekfetch": "^3.5.0",
+ "prism-media": "hydrabolt/prism-media",
+ "snekfetch": "^3.6.0",
"tweetnacl": "^1.0.0",
- "ws": "^3.3.1"
+ "ws": "^4.0.0"
},
"peerDependencies": {
"bufferutil": "^3.0.0",
"erlpack": "discordapp/erlpack",
- "node-opus": "^0.2.0",
- "opusscript": "^0.0.4",
"sodium": "^2.0.0",
"libsodium-wrappers": "^0.7.0",
- "uws": "^8.14.0",
+ "uws": "^9.14.0",
"zlib-sync": "^0.1.0"
},
"devDependencies": {
- "@types/node": "^8.0.0",
- "discord.js-docgen": "hydrabolt/discord.js-docgen",
- "eslint": "^4.11.0",
- "jsdoc-strip-async-await": "^0.1.0",
+ "@types/node": "^9.4.6",
+ "discord.js-docgen": "discordjs/docgen",
+ "eslint": "^4.17.0",
"json-filter-loader": "^1.0.0",
- "uglifyjs-webpack-plugin": "^1.0.0-beta.2",
- "webpack": "^3.8.0"
+ "uglifyjs-webpack-plugin": "^1.1.8",
+ "webpack": "^3.11.0"
},
"engines": {
"node": ">=8.0.0"
@@ -78,21 +75,13 @@
"src/sharding/ShardingManager.js": false,
"src/client/voice/ClientVoiceManager.js": false,
"src/client/voice/VoiceConnection.js": false,
- "src/client/voice/VoiceUDPClient.js": false,
- "src/client/voice/VoiceWebSocket.js": false,
+ "src/client/voice/networking/VoiceUDPClient.js": false,
+ "src/client/voice/networking/VoiceWebSocket.js": false,
"src/client/voice/dispatcher/StreamDispatcher.js": false,
- "src/client/voice/opus/BaseOpusEngine.js": false,
- "src/client/voice/opus/NodeOpusEngine.js": false,
- "src/client/voice/opus/OpusEngineList.js": false,
- "src/client/voice/opus/OpusScriptEngine.js": false,
- "src/client/voice/pcm/ConverterEngine.js": false,
- "src/client/voice/pcm/ConverterEngineList.js": false,
- "src/client/voice/pcm/FfmpegConverterEngine.js": false,
"src/client/voice/player/AudioPlayer.js": false,
- "src/client/voice/receiver/VoiceReadable.js": false,
- "src/client/voice/receiver/VoiceReceiver.js": false,
+ "src/client/voice/receiver/PacketHandler.js": false,
+ "src/client/voice/receiver/Receiver.js": false,
"src/client/voice/util/Secretbox.js": false,
- "src/client/voice/util/SecretKey.js": false,
"src/client/voice/util/VolumeInterface.js": false,
"src/client/voice/VoiceBroadcast.js": false
}
diff --git a/src/client/Client.js b/src/client/Client.js
index 4b335e88a..475d51d62 100644
--- a/src/client/Client.js
+++ b/src/client/Client.js
@@ -15,7 +15,7 @@ const UserStore = require('../stores/UserStore');
const ChannelStore = require('../stores/ChannelStore');
const GuildStore = require('../stores/GuildStore');
const ClientPresenceStore = require('../stores/ClientPresenceStore');
-const EmojiStore = require('../stores/EmojiStore');
+const GuildEmojiStore = require('../stores/GuildEmojiStore');
const { Events, browser } = require('../util/Constants');
const DataResolver = require('../util/DataResolver');
const { Error, TypeError, RangeError } = require('../errors');
@@ -70,11 +70,10 @@ class Client extends BaseClient {
this.voice = !browser ? new ClientVoiceManager(this) : null;
/**
- * The shard helpers for the client
- * (only if the process was spawned as a child, such as from a {@link ShardingManager})
+ * Shard helpers for the client (only if the process was spawned from a {@link ShardingManager})
* @type {?ShardClientUtil}
*/
- this.shard = !browser && process.send ? ShardClientUtil.singleton(this) : null;
+ this.shard = !browser && process.env.SHARDING_MANAGER ? ShardClientUtil.singleton(this) : null;
/**
* All of the {@link User} objects that have been cached at any point, mapped by their IDs
@@ -180,7 +179,7 @@ class Client extends BaseClient {
}
/**
- * How long it has been since the client last entered the `READY` state
+ * How long it has been since the client last entered the `READY` state in milliseconds
* @type {?number}
* @readonly
*/
@@ -209,11 +208,11 @@ class Client extends BaseClient {
/**
* All custom emojis that the client has access to, mapped by their IDs
- * @type {EmojiStore}
+ * @type {GuildEmojiStore}
* @readonly
*/
get emojis() {
- const emojis = new EmojiStore({ client: this });
+ const emojis = new GuildEmojiStore({ client: this });
for (const guild of this.guilds.values()) {
if (guild.available) for (const emoji of guild.emojis.values()) emojis.set(emoji.id, emoji);
}
@@ -288,6 +287,11 @@ class Client extends BaseClient {
* Obtains an invite from Discord.
* @param {InviteResolvable} invite Invite code or URL
* @returns {Promise}
+ * @example
+ * client.fetchInvite('https://discord.gg/bRCvFy9')
+ * .then(invite => {
+ * console.log(`Obtained invite with code: ${invite.code}`);
+ * }).catch(console.error);
*/
fetchInvite(invite) {
const code = DataResolver.resolveInviteCode(invite);
@@ -300,6 +304,11 @@ class Client extends BaseClient {
* @param {Snowflake} id ID of the webhook
* @param {string} [token] Token for the webhook
* @returns {Promise}
+ * @example
+ * client.fetchWebhook('id', 'token')
+ * .then(webhook => {
+ * console.log(`Obtained webhook with name: ${webhook.name}`);
+ * }).catch(console.error);
*/
fetchWebhook(id, token) {
return this.api.webhooks(id, token).get().then(data => new Webhook(this, data));
@@ -308,6 +317,11 @@ class Client extends BaseClient {
/**
* Obtains the available voice regions from Discord.
* @returns {Collection}
+ * @example
+ * client.fetchVoiceRegions()
+ * .then(regions => {
+ * console.log(`Available regions are: ${regions.map(region => region.name).join(', ')}`);
+ * }).catch(console.error);
*/
fetchVoiceRegions() {
return this.api.voice.regions.get().then(res => {
@@ -324,6 +338,10 @@ class Client extends BaseClient {
* will be removed from the caches. The default is based on {@link ClientOptions#messageCacheLifetime}
* @returns {number} Amount of messages that were removed from the caches,
* or -1 if the message cache lifetime is unlimited
+ * @example
+ * // Remove all messages older than 1800 seconds from the messages cache
+ * const amount = client.sweepMessages(1800);
+ * console.log(`Successfully removed ${amount} messages from the cache.`);
*/
sweepMessages(lifetime = this.options.messageCacheLifetime) {
if (typeof lifetime !== 'number' || isNaN(lifetime)) {
@@ -360,6 +378,11 @@ class Client extends BaseClient {
* Obtains the OAuth Application of the bot from Discord.
* @param {Snowflake} [id='@me'] ID of application to fetch
* @returns {Promise}
+ * @example
+ * client.fetchApplication('id')
+ * .then(application => {
+ * console.log(`Obtained application with name: ${application.name}`);
+ * }).catch(console.error);
*/
fetchApplication(id = '@me') {
return this.api.oauth2.applications(id).get()
@@ -375,7 +398,7 @@ class Client extends BaseClient {
* client.generateInvite(['SEND_MESSAGES', 'MANAGE_GUILD', 'MENTION_EVERYONE'])
* .then(link => {
* console.log(`Generated bot invite link: ${link}`);
- * });
+ * }).catch(console.error);
*/
generateInvite(permissions) {
if (permissions) {
diff --git a/src/client/ClientManager.js b/src/client/ClientManager.js
index ad006b8fe..f1891e0ce 100644
--- a/src/client/ClientManager.js
+++ b/src/client/ClientManager.js
@@ -39,7 +39,10 @@ class ClientManager {
this.client.emit(Events.DEBUG, `Authenticated using token ${token}`);
this.client.token = token;
const timeout = this.client.setTimeout(() => reject(new Error('WS_CONNECTION_TIMEOUT')), 1000 * 300);
- this.client.api.gateway.get().then(res => {
+ this.client.api.gateway.get().then(async res => {
+ if (this.client.options.presence != null) { // eslint-disable-line eqeqeq
+ this.client.options.ws.presence = await this.client.presences._parse(this.client.options.presence);
+ }
const gateway = `${res.url}/`;
this.client.emit(Events.DEBUG, `Using gateway ${gateway}`);
this.client.ws.connect(gateway);
diff --git a/src/client/actions/ChannelCreate.js b/src/client/actions/ChannelCreate.js
index 1195d7345..09e74f0f3 100644
--- a/src/client/actions/ChannelCreate.js
+++ b/src/client/actions/ChannelCreate.js
@@ -5,7 +5,7 @@ class ChannelCreateAction extends Action {
handle(data) {
const client = this.client;
const existing = client.channels.has(data.id);
- const channel = client.channels.create(data);
+ const channel = client.channels.add(data);
if (!existing && channel) {
client.emit(Events.CHANNEL_CREATE, channel);
}
diff --git a/src/client/actions/GuildBanRemove.js b/src/client/actions/GuildBanRemove.js
index fe15f17f7..782b5fe2a 100644
--- a/src/client/actions/GuildBanRemove.js
+++ b/src/client/actions/GuildBanRemove.js
@@ -5,7 +5,7 @@ class GuildBanRemove extends Action {
handle(data) {
const client = this.client;
const guild = client.guilds.get(data.guild_id);
- const user = client.users.create(data.user);
+ const user = client.users.add(data.user);
if (guild && user) client.emit(Events.GUILD_BAN_REMOVE, guild, user);
}
}
diff --git a/src/client/actions/GuildEmojiCreate.js b/src/client/actions/GuildEmojiCreate.js
index cac3d8c4d..7fc955a0f 100644
--- a/src/client/actions/GuildEmojiCreate.js
+++ b/src/client/actions/GuildEmojiCreate.js
@@ -3,7 +3,7 @@ const { Events } = require('../../util/Constants');
class GuildEmojiCreateAction extends Action {
handle(guild, createdEmoji) {
- const emoji = guild.emojis.create(createdEmoji);
+ const emoji = guild.emojis.add(createdEmoji);
this.client.emit(Events.GUILD_EMOJI_CREATE, emoji);
return { emoji };
}
@@ -12,7 +12,7 @@ class GuildEmojiCreateAction extends Action {
/**
* Emitted whenever a custom emoji is created in a guild.
* @event Client#emojiCreate
- * @param {Emoji} emoji The emoji that was created
+ * @param {GuildEmoji} emoji The emoji that was created
*/
module.exports = GuildEmojiCreateAction;
diff --git a/src/client/actions/GuildEmojiDelete.js b/src/client/actions/GuildEmojiDelete.js
index 36a674b33..d8a83fc3e 100644
--- a/src/client/actions/GuildEmojiDelete.js
+++ b/src/client/actions/GuildEmojiDelete.js
@@ -10,9 +10,9 @@ class GuildEmojiDeleteAction extends Action {
}
/**
- * Emitted whenever a custom guild emoji is deleted.
+ * Emitted whenever a custom emoji is deleted in a guild.
* @event Client#emojiDelete
- * @param {Emoji} emoji The emoji that was deleted
+ * @param {GuildEmoji} emoji The emoji that was deleted
*/
module.exports = GuildEmojiDeleteAction;
diff --git a/src/client/actions/GuildEmojiUpdate.js b/src/client/actions/GuildEmojiUpdate.js
index b3ebb4b63..e6accf2c5 100644
--- a/src/client/actions/GuildEmojiUpdate.js
+++ b/src/client/actions/GuildEmojiUpdate.js
@@ -10,10 +10,10 @@ class GuildEmojiUpdateAction extends Action {
}
/**
- * Emitted whenever a custom guild emoji is updated.
+ * Emitted whenever a custom emoji is updated in a guild.
* @event Client#emojiUpdate
- * @param {Emoji} oldEmoji The old emoji
- * @param {Emoji} newEmoji The new emoji
+ * @param {GuildEmoji} oldEmoji The old emoji
+ * @param {GuildEmoji} newEmoji The new emoji
*/
module.exports = GuildEmojiUpdateAction;
diff --git a/src/client/actions/GuildEmojisUpdate.js b/src/client/actions/GuildEmojisUpdate.js
index 8656a34cd..90f43eeba 100644
--- a/src/client/actions/GuildEmojisUpdate.js
+++ b/src/client/actions/GuildEmojisUpdate.js
@@ -1,17 +1,11 @@
const Action = require('./Action');
-function mappify(iterable) {
- const map = new Map();
- for (const x of iterable) map.set(...x);
- return map;
-}
-
class GuildEmojisUpdateAction extends Action {
handle(data) {
const guild = this.client.guilds.get(data.guild_id);
if (!guild || !guild.emojis) return;
- const deletions = mappify(guild.emojis.entries());
+ const deletions = new Map(guild.emojis);
for (const emoji of data.emojis) {
// Determine type of emoji event
diff --git a/src/client/actions/GuildMemberRemove.js b/src/client/actions/GuildMemberRemove.js
index b649eba8d..95bff6abf 100644
--- a/src/client/actions/GuildMemberRemove.js
+++ b/src/client/actions/GuildMemberRemove.js
@@ -8,8 +8,8 @@ class GuildMemberRemoveAction extends Action {
let member = null;
if (guild) {
member = guild.members.get(data.user.id);
+ guild.memberCount--;
if (member) {
- guild.memberCount--;
guild.members.remove(member.id);
if (client.status === Status.READY) client.emit(Events.GUILD_MEMBER_REMOVE, member);
}
diff --git a/src/client/actions/GuildRoleCreate.js b/src/client/actions/GuildRoleCreate.js
index 7f5bbb485..b4930399d 100644
--- a/src/client/actions/GuildRoleCreate.js
+++ b/src/client/actions/GuildRoleCreate.js
@@ -8,7 +8,7 @@ class GuildRoleCreate extends Action {
let role;
if (guild) {
const already = guild.roles.has(data.role.id);
- role = guild.roles.create(data.role);
+ role = guild.roles.add(data.role);
if (!already) client.emit(Events.GUILD_ROLE_CREATE, role);
}
return { role };
diff --git a/src/client/actions/GuildSync.js b/src/client/actions/GuildSync.js
index 2019c5d22..f7dbde6ad 100644
--- a/src/client/actions/GuildSync.js
+++ b/src/client/actions/GuildSync.js
@@ -7,7 +7,7 @@ class GuildSync extends Action {
const guild = client.guilds.get(data.id);
if (guild) {
if (data.presences) {
- for (const presence of data.presences) guild.presences.create(presence);
+ for (const presence of data.presences) guild.presences.add(presence);
}
if (data.members) {
@@ -16,7 +16,7 @@ class GuildSync extends Action {
if (member) {
member._patch(syncMember);
} else {
- guild.members.create(syncMember, false);
+ guild.members.add(syncMember, false);
}
}
}
diff --git a/src/client/actions/MessageCreate.js b/src/client/actions/MessageCreate.js
index 1755cec71..e76c6071d 100644
--- a/src/client/actions/MessageCreate.js
+++ b/src/client/actions/MessageCreate.js
@@ -8,7 +8,7 @@ class MessageCreateAction extends Action {
if (channel) {
const existing = channel.messages.get(data.id);
if (existing) return { message: existing };
- const message = channel.messages.create(data);
+ const message = channel.messages.add(data);
const user = message.author;
const member = channel.guild ? channel.guild.member(user) : null;
channel.lastMessageID = data.id;
diff --git a/src/client/actions/MessageReactionAdd.js b/src/client/actions/MessageReactionAdd.js
index b26ad5949..9d307ceee 100644
--- a/src/client/actions/MessageReactionAdd.js
+++ b/src/client/actions/MessageReactionAdd.js
@@ -19,7 +19,7 @@ class MessageReactionAdd extends Action {
if (!message) return false;
if (!data.emoji) return false;
// Verify reaction
- const reaction = message.reactions.create({
+ const reaction = message.reactions.add({
emoji: data.emoji,
count: 0,
me: user.id === this.client.user.id,
@@ -33,7 +33,7 @@ class MessageReactionAdd extends Action {
* Emitted whenever a reaction is added to a message.
* @event Client#messageReactionAdd
* @param {MessageReaction} messageReaction The reaction object
- * @param {User} user The user that applied the emoji or reaction emoji
+ * @param {User} user The user that applied the guild or reaction emoji
*/
module.exports = MessageReactionAdd;
diff --git a/src/client/voice/VoiceBroadcast.js b/src/client/voice/VoiceBroadcast.js
index 5a2c54c73..1bc4e2827 100644
--- a/src/client/voice/VoiceBroadcast.js
+++ b/src/client/voice/VoiceBroadcast.js
@@ -1,15 +1,7 @@
-const VolumeInterface = require('./util/VolumeInterface');
-const Prism = require('prism-media');
-const OpusEncoders = require('./opus/OpusEngineList');
-const Collection = require('../../util/Collection');
-
-const ffmpegArguments = [
- '-analyzeduration', '0',
- '-loglevel', '0',
- '-f', 's16le',
- '-ar', '48000',
- '-ac', '2',
-];
+const EventEmitter = require('events');
+const BroadcastAudioPlayer = require('./player/BroadcastAudioPlayer');
+const DispatcherSet = require('./util/DispatcherSet');
+const PlayInterface = require('./util/PlayInterface');
/**
* A voice broadcast can be played across multiple voice connections for improved shared-stream efficiency.
@@ -17,15 +9,15 @@ const ffmpegArguments = [
* Example usage:
* ```js
* const broadcast = client.createVoiceBroadcast();
- * broadcast.playFile('./music.mp3');
+ * broadcast.play('./music.mp3');
* // Play "music.mp3" in all voice connections that the client is in
* for (const connection of client.voiceConnections.values()) {
- * connection.playBroadcast(broadcast);
+ * connection.play(broadcast);
* }
* ```
- * @implements {VolumeInterface}
+ * @implements {PlayInterface}
*/
-class VoiceBroadcast extends VolumeInterface {
+class VoiceBroadcast extends EventEmitter {
constructor(client) {
super();
/**
@@ -33,339 +25,36 @@ class VoiceBroadcast extends VolumeInterface {
* @type {Client}
*/
this.client = client;
- this._dispatchers = new Collection();
- this._encoders = new Collection();
- /**
- * Whether playing is paused
- * @type {boolean}
- */
- this.paused = false;
- /**
- * The audio transcoder that this broadcast uses
- * @type {Prism}
- */
- this.prism = new Prism();
- /**
- * The current audio transcoder that is being used
- * @type {Object}
- */
- this.currentTranscoder = null;
- this.tickInterval = null;
- this._volume = 1;
+ this.dispatchers = new DispatcherSet(this);
+ this.player = new BroadcastAudioPlayer(this);
}
/**
- * An array of subscribed dispatchers
- * @type {StreamDispatcher[]}
- * @readonly
+ * The current master dispatcher, if any. This dispatcher controls all that is played by subscribed dispatchers.
+ * @type {?BroadcastDispatcher}
*/
- get dispatchers() {
- let d = [];
- for (const container of this._dispatchers.values()) {
- d = d.concat(Array.from(container.values()));
- }
- return d;
- }
-
- get _playableStream() {
- const currentTranscoder = this.currentTranscoder;
- if (!currentTranscoder) return null;
- const transcoder = currentTranscoder.transcoder;
- const options = currentTranscoder.options;
- return (transcoder && transcoder.output) || options.stream;
- }
-
- unregisterDispatcher(dispatcher, old) {
- const volume = old || dispatcher.volume;
-
- /**
- * Emitted whenever a stream dispatcher unsubscribes from the broadcast.
- * @event VoiceBroadcast#unsubscribe
- * @param {StreamDispatcher} dispatcher The unsubscribed dispatcher
- */
- this.emit('unsubscribe', dispatcher);
- for (const container of this._dispatchers.values()) {
- container.delete(dispatcher);
-
- if (!container.size) {
- this._encoders.get(volume).destroy();
- this._dispatchers.delete(volume);
- this._encoders.delete(volume);
- }
- }
- }
-
- registerDispatcher(dispatcher) {
- if (!this._dispatchers.has(dispatcher.volume)) {
- this._dispatchers.set(dispatcher.volume, new Set());
- this._encoders.set(dispatcher.volume, OpusEncoders.fetch());
- }
- const container = this._dispatchers.get(dispatcher.volume);
- if (!container.has(dispatcher)) {
- container.add(dispatcher);
- dispatcher.once('end', () => this.unregisterDispatcher(dispatcher));
- dispatcher.on('volumeChange', (o, n) => {
- this.unregisterDispatcher(dispatcher, o);
- if (!this._dispatchers.has(n)) {
- this._dispatchers.set(n, new Set());
- this._encoders.set(n, OpusEncoders.fetch());
- }
- this._dispatchers.get(n).add(dispatcher);
- });
- /**
- * Emitted whenever a stream dispatcher subscribes to the broadcast.
- * @event VoiceBroadcast#subscribe
- * @param {StreamDispatcher} dispatcher The subscribed dispatcher
- */
- this.emit('subscribe', dispatcher);
- }
- }
-
- killCurrentTranscoder() {
- if (this.currentTranscoder) {
- if (this.currentTranscoder.transcoder) this.currentTranscoder.transcoder.kill();
- this.currentTranscoder = null;
- this.emit('end');
- }
+ get dispatcher() {
+ return this.player.dispatcher;
}
/**
- * Plays any audio stream across the broadcast.
- * @param {ReadableStream} stream The audio stream to play
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {VoiceBroadcast}
+ * Play an audio resource.
+ * @param {ReadableStream|string} resource The resource to play.
+ * @param {StreamOptions} [options] The options to play.
* @example
- * // Play streams using ytdl-core
- * const ytdl = require('ytdl-core');
- * const streamOptions = { seek: 0, volume: 1 };
- * 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);
- */
- playStream(stream, options = {}) {
- this.setVolume(options.volume || 1);
- return this._playTranscodable(stream, options);
- }
-
- /**
- * Plays the given file in the voice connection.
- * @param {string} file The absolute path to the file
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {StreamDispatcher}
+ * // Play a local audio file
+ * broadcast.play('/home/hydrabolt/audio.mp3', { volume: 0.5 });
* @example
- * // Play files natively
- * const broadcast = client.createVoiceBroadcast();
- *
- * voiceChannel.join()
- * .then(connection => {
- * broadcast.playFile('C:/Users/Discord/Desktop/music.mp3');
- * const dispatcher = connection.playBroadcast(broadcast);
- * })
- * .catch(console.error);
+ * // Play a ReadableStream
+ * broadcast.play(ytdl('https://www.youtube.com/watch?v=ZlAU_w7-Xp8', { filter: 'audioonly' }));
+ * @example
+ * // Using different protocols: https://ffmpeg.org/ffmpeg-protocols.html
+ * broadcast.play('http://www.sample-videos.com/audio/mp3/wave.mp3');
+ * @returns {BroadcastDispatcher}
*/
- playFile(file, options = {}) {
- this.setVolume(options.volume || 1);
- return this._playTranscodable(`file:${file}`, options);
- }
-
- _playTranscodable(media, options) {
- this.killCurrentTranscoder();
- const transcoder = this.prism.transcode({
- type: 'ffmpeg',
- media,
- ffmpegArguments: ffmpegArguments.concat(['-ss', String(options.seek || 0)]),
- });
- /**
- * Emitted whenever an error occurs.
- * @event VoiceBroadcast#error
- * @param {Error} error The error that occurred
- */
- transcoder.once('error', e => {
- if (this.listenerCount('error') > 0) this.emit('error', e);
- /**
- * Emitted whenever the VoiceBroadcast has any warnings.
- * @event VoiceBroadcast#warn
- * @param {string|Error} warning The warning that was raised
- */
- else this.emit('warn', e);
- });
- /**
- * Emitted once the broadcast (the audio stream) ends.
- * @event VoiceBroadcast#end
- */
- transcoder.once('end', () => this.killCurrentTranscoder());
- this.currentTranscoder = {
- transcoder,
- options,
- };
- transcoder.output.once('readable', () => this._startPlaying());
- return this;
- }
-
- /**
- * 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, options = {}) {
- this.killCurrentTranscoder();
- this.setVolume(options.volume || 1);
- this.currentTranscoder = { options: { stream } };
- stream.once('readable', () => this._startPlaying());
- return this;
- }
-
- /**
- * 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) {
- this.currentTranscoder = { options: { stream }, opus: true };
- stream.once('readable', () => this._startPlaying());
- return this;
- }
-
- /**
- * Plays an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description).
- * @param {string} input The arbitrary input
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {VoiceBroadcast}
- */
- playArbitraryInput(input, options = {}) {
- this.setVolume(options.volume || 1);
- options.input = input;
- return this._playTranscodable(input, options);
- }
-
- /**
- * Pauses the entire broadcast - all dispatchers are also paused.
- */
- pause() {
- this.paused = true;
- for (const container of this._dispatchers.values()) {
- for (const dispatcher of container.values()) {
- dispatcher.pause();
- }
- }
- }
-
- /**
- * Resumes the entire broadcast - all dispatchers are also resumed.
- */
- resume() {
- this.paused = false;
- for (const container of this._dispatchers.values()) {
- for (const dispatcher of container.values()) {
- dispatcher.resume();
- }
- }
- }
-
- _startPlaying() {
- if (this.tickInterval) clearInterval(this.tickInterval);
- // Old code?
- // this.tickInterval = this.client.setInterval(this.tick.bind(this), 20);
- this._startTime = Date.now();
- this._count = 0;
- this._pausedTime = 0;
- this._missed = 0;
- this.tick();
- }
-
- tick() {
- if (!this._playableStream) return;
- if (this.paused) {
- this._pausedTime += 20;
- setTimeout(() => this.tick(), 20);
- return;
- }
-
- const opus = this.currentTranscoder.opus;
- const buffer = this.readStreamBuffer();
-
- if (!buffer) {
- this._missed++;
- if (this._missed < 5) {
- this._pausedTime += 200;
- setTimeout(() => this.tick(), 200);
- } else {
- this.killCurrentTranscoder();
- }
- return;
- }
-
- this._missed = 0;
-
- let packetMatrix = {};
-
- const getOpusPacket = volume => {
- if (packetMatrix[volume]) return packetMatrix[volume];
-
- const opusEncoder = this._encoders.get(volume);
- const opusPacket = opusEncoder.encode(this.applyVolume(buffer, this._volume * volume));
- packetMatrix[volume] = opusPacket;
- return opusPacket;
- };
-
- for (const dispatcher of this.dispatchers) {
- if (opus) {
- dispatcher.processPacket(buffer);
- continue;
- }
-
- const volume = dispatcher.volume;
- dispatcher.processPacket(getOpusPacket(volume));
- }
-
- const next = 20 + (this._startTime + this._pausedTime + (this._count * 20) - Date.now());
- this._count++;
- setTimeout(() => this.tick(), next);
- }
-
- readStreamBuffer() {
- const opus = this.currentTranscoder.opus;
- const bufferLength = (opus ? 80 : 1920) * 2;
- let buffer = this._playableStream.read(bufferLength);
- if (opus) return buffer;
- if (!buffer) return null;
-
- if (buffer.length !== bufferLength) {
- const newBuffer = Buffer.alloc(bufferLength).fill(0);
- buffer.copy(newBuffer);
- buffer = newBuffer;
- }
-
- return buffer;
- }
-
- /**
- * Stops the current stream from playing without unsubscribing dispatchers.
- */
- end() {
- this.killCurrentTranscoder();
- }
-
- /**
- * Ends the current broadcast, all subscribed dispatchers will also end.
- */
- destroy() {
- this.end();
- for (const container of this._dispatchers.values()) {
- for (const dispatcher of container.values()) {
- dispatcher.destroy('end', 'broadcast ended');
- }
- }
- }
+ play() { return null; }
}
+PlayInterface.applyToClass(VoiceBroadcast);
+
module.exports = VoiceBroadcast;
diff --git a/src/client/voice/VoiceConnection.js b/src/client/voice/VoiceConnection.js
index 1953d6b07..f99e3c265 100644
--- a/src/client/voice/VoiceConnection.js
+++ b/src/client/voice/VoiceConnection.js
@@ -1,12 +1,12 @@
-const VoiceWebSocket = require('./VoiceWebSocket');
-const VoiceUDP = require('./VoiceUDPClient');
+const VoiceWebSocket = require('./networking/VoiceWebSocket');
+const VoiceUDP = require('./networking/VoiceUDPClient');
const Util = require('../../util/Util');
const { OPCodes, VoiceOPCodes, VoiceStatus } = require('../../util/Constants');
const AudioPlayer = require('./player/AudioPlayer');
-const VoiceReceiver = require('./receiver/VoiceReceiver');
+const VoiceReceiver = require('./receiver/Receiver');
const EventEmitter = require('events');
-const Prism = require('prism-media');
const { Error } = require('../../errors');
+const PlayInterface = require('./util/PlayInterface');
/**
* Represents a connection to a guild's voice server.
@@ -18,6 +18,7 @@ const { Error } = require('../../errors');
* });
* ```
* @extends {EventEmitter}
+ * @implements {PlayInterface}
*/
class VoiceConnection extends EventEmitter {
constructor(voiceManager, channel) {
@@ -35,17 +36,6 @@ class VoiceConnection extends EventEmitter {
*/
this.client = voiceManager.client;
- /**
- * @external Prism
- * @see {@link https://github.com/hydrabolt/prism-media}
- */
-
- /**
- * The audio transcoder for this connection
- * @type {Prism}
- */
- this.prism = new Prism();
-
/**
* The voice channel this connection is currently serving
* @type {VoiceChannel}
@@ -101,6 +91,8 @@ class VoiceConnection extends EventEmitter {
this.emit('warn', e);
});
+ this.once('closing', () => this.player.destroy());
+
/**
* Map SSRC to speaking values
* @type {Map}
@@ -425,11 +417,6 @@ class VoiceConnection extends EventEmitter {
const guild = this.channel.guild;
const user = this.client.users.get(user_id);
this.ssrcMap.set(+ssrc, user);
- if (!speaking) {
- for (const receiver of this.receivers) {
- receiver.stoppedSpeaking(user);
- }
- }
/**
* Emitted whenever a user starts/stops speaking.
* @event VoiceConnection#speaking
@@ -440,100 +427,6 @@ class VoiceConnection extends EventEmitter {
guild._memberSpeakUpdate(user_id, speaking);
}
- /**
- * Options that can be passed to stream-playing methods:
- * @typedef {Object} StreamOptions
- * @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
- */
-
- /**
- * Plays the given file in the voice connection.
- * @param {string} file The absolute path to the file
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {StreamDispatcher}
- * @example
- * // Play files natively
- * voiceChannel.join()
- * .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);
- }
-
- /**
- * Plays an arbitrary input that can be [handled by ffmpeg](https://ffmpeg.org/ffmpeg-protocols.html#Description)
- * @param {string} input the arbitrary input
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {StreamDispatcher}
- */
- playArbitraryInput(input, options) {
- return this.player.playUnknownStream(input, options);
- }
-
- /**
- * Plays and converts an audio stream in the voice connection.
- * @param {ReadableStream} stream The audio stream to play
- * @param {StreamOptions} [options] Options for playing the stream
- * @returns {StreamDispatcher}
- * @example
- * // Play streams using ytdl-core
- * 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);
- */
- playStream(stream, options) {
- return this.player.playUnknownStream(stream, options);
- }
-
- /**
- * 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}
- */
- playConvertedStream(stream, options) {
- return this.player.playPCMStream(stream, options);
- }
-
- /**
- * 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, options) {
- return this.player.playOpusStream(stream, options);
- }
-
- /**
- * 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
- * const broadcast = client
- * .createVoiceBroadcast()
- * .playFile('./test.mp3');
- * const dispatcher = voiceConnection.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.
@@ -544,6 +437,10 @@ class VoiceConnection extends EventEmitter {
this.receivers.push(receiver);
return receiver;
}
+
+ play() {} // eslint-disable-line no-empty-function
}
+PlayInterface.applyToClass(VoiceConnection);
+
module.exports = VoiceConnection;
diff --git a/src/client/voice/dispatcher/BroadcastDispatcher.js b/src/client/voice/dispatcher/BroadcastDispatcher.js
new file mode 100644
index 000000000..90cff6a40
--- /dev/null
+++ b/src/client/voice/dispatcher/BroadcastDispatcher.js
@@ -0,0 +1,38 @@
+const StreamDispatcher = require('./StreamDispatcher');
+
+/**
+ * The class that sends voice packet data to the voice connection.
+ * @implements {VolumeInterface}
+ * @extends {StreamDispatcher}
+ */
+class BroadcastDispatcher extends StreamDispatcher {
+ constructor(player, options, streams) {
+ super(player, options, streams);
+ this.broadcast = player.broadcast;
+ }
+
+ _write(chunk, enc, done) {
+ if (!this.startTime) this.startTime = Date.now();
+ for (const dispatcher of this.broadcast.dispatchers) {
+ dispatcher._write(chunk, enc);
+ }
+ this._step(done);
+ }
+
+ _destroy(err, cb) {
+ if (this.player.dispatcher === this) this.player.dispatcher = null;
+ const { streams } = this;
+ if (streams.opus) streams.opus.unpipe(this);
+ if (streams.ffmpeg) streams.ffmpeg.destroy();
+ super._destroy(err, cb);
+ }
+
+ setBitrate(value) {
+ if (!value || !this.streams.opus || !this.streams.opus.setBitrate) return false;
+ const bitrate = value === 'auto' ? 48 : value;
+ this.streams.opus.setBitrate(bitrate * 1000);
+ return true;
+ }
+}
+
+module.exports = BroadcastDispatcher;
diff --git a/src/client/voice/dispatcher/StreamDispatcher.js b/src/client/voice/dispatcher/StreamDispatcher.js
index d128039eb..b99be2e88 100644
--- a/src/client/voice/dispatcher/StreamDispatcher.js
+++ b/src/client/voice/dispatcher/StreamDispatcher.js
@@ -1,158 +1,221 @@
-const VolumeInterface = require('../util/VolumeInterface');
-const VoiceBroadcast = require('../VoiceBroadcast');
const { VoiceStatus } = require('../../../util/Constants');
+const VolumeInterface = require('../util/VolumeInterface');
+const { Writable } = require('stream');
const secretbox = require('../util/Secretbox');
+const FRAME_LENGTH = 20;
+const CHANNELS = 2;
+const TIMESTAMP_INC = (48000 / 100) * CHANNELS;
+
const nonce = Buffer.alloc(24);
nonce.fill(0);
+/**
+ * @external WritableStream
+ * @see {@link https://nodejs.org/api/stream.html#stream_class_stream_writable}
+ */
+
/**
* The class that sends voice packet data to the voice connection.
* ```js
* // Obtained using:
* voiceChannel.join().then(connection => {
* // You can play a file or a stream here:
- * const dispatcher = connection.playFile('./file.mp3');
+ * const dispatcher = connection.play('/home/hydrabolt/audio.mp3');
* });
* ```
* @implements {VolumeInterface}
+ * @extends {WritableStream}
*/
-class StreamDispatcher extends VolumeInterface {
- constructor(player, stream, streamOptions) {
+class StreamDispatcher extends Writable {
+ constructor(
+ player,
+ { seek = 0, volume = 1, passes = 1, fec, plp, bitrate = 96, highWaterMark = 12 } = {},
+ streams) {
+ const streamOptions = { seek, volume, passes, fec, plp, bitrate, highWaterMark };
super(streamOptions);
/**
* The Audio Player that controls this dispatcher
* @type {AudioPlayer}
*/
this.player = player;
- /**
- * The stream that the dispatcher plays
- * @type {ReadableStream|VoiceBroadcast}
- */
- this.stream = stream;
- if (!(this.stream instanceof VoiceBroadcast)) this.startStreaming();
this.streamOptions = streamOptions;
-
- const data = this.streamingData;
- data.length = 20;
- data.missed = 0;
+ this.streams = streams;
/**
- * Whether playing is paused
- * @type {boolean}
+ * The time that the stream was paused at (null if not paused)
+ * @type {?number}
*/
- this.paused = false;
+ this.pausedSince = null;
+ this._writeCallback = null;
+
/**
- * Whether this dispatcher has been destroyed
- * @type {boolean}
+ * The broadcast controlling this dispatcher, if any
+ * @type {?VoiceBroadcast}
*/
- this.destroyed = false;
+ this.broadcast = this.streams.broadcast;
- this._opus = streamOptions.opus;
+ this._pausedTime = 0;
+ this.count = 0;
+
+ this.on('finish', () => {
+ // Still emitting end for backwards compatibility, probably remove it in the future!
+ this.emit('end');
+ });
+
+ if (typeof volume !== 'undefined') this.setVolume(volume);
+ if (typeof fec !== 'undefined') this.setFEC(fec);
+ if (typeof plp !== 'undefined') this.setPLP(plp);
+ if (typeof bitrate !== 'undefined') this.setBitrate(bitrate);
+
+ const streamError = (type, err) => {
+ /**
+ * Emitted when the dispatcher encounters an error.
+ * @event StreamDispatcher#error
+ */
+ if (type && err) {
+ err.message = `${type} stream: ${err.message}`;
+ this.emit(this.player.dispatcher === this ? 'error' : 'debug', err);
+ }
+ this.destroy();
+ };
+
+ this.on('error', () => streamError());
+ if (this.streams.input) this.streams.input.on('error', err => streamError('input', err));
+ if (this.streams.ffmpeg) this.streams.ffmpeg.on('error', err => streamError('ffmpeg', err));
+ if (this.streams.opus) this.streams.opus.on('error', err => streamError('opus', err));
+ if (this.streams.volume) this.streams.volume.on('error', err => streamError('volume', err));
}
- /**
- * How many passes the dispatcher should take when sending packets to reduce packet loss. Values over 5
- * aren't recommended, as it means you are using 5x more bandwidth. You _can_ edit this at runtime
- * @type {number}
- * @readonly
- */
- get passes() {
- return this.streamOptions.passes || 1;
- }
-
- set passes(n) {
- this.streamOptions.passes = n;
- }
-
- get streamingData() {
+ get _sdata() {
return this.player.streamingData;
}
- /**
- * How long the stream dispatcher has been "speaking" for
- * @type {number}
- * @readonly
- */
- get time() {
- return this.streamingData.count * (this.streamingData.length || 0);
+ _write(chunk, enc, done) {
+ if (!this.startTime) {
+ /**
+ * Emitted once the stream has started to play.
+ * @event StreamDispatcher#start
+ */
+ this.emit('start');
+ this.startTime = Date.now();
+ }
+ this._playChunk(chunk);
+ this._step(done);
+ }
+
+ _destroy(err, cb) {
+ if (this.player.dispatcher === this) this.player.dispatcher = null;
+ const { streams } = this;
+ if (streams.broadcast) streams.broadcast.dispatchers.delete(this);
+ if (streams.opus) streams.opus.unpipe(this);
+ if (streams.ffmpeg) streams.ffmpeg.destroy();
+ super._destroy(err, cb);
}
/**
- * The total time, taking into account pauses and skips, that the dispatcher has been streaming for
+ * Pauses playback
+ */
+ pause() {
+ this.pausedSince = Date.now();
+ }
+
+ /**
+ * Whether or not playback is paused
+ * @type {boolean}
+ */
+ get paused() { return Boolean(this.pausedSince); }
+
+ /**
+ * Total time that this dispatcher has been paused
+ * @type {number}
+ */
+ get pausedTime() { return this._pausedTime + (this.paused ? Date.now() - this.pausedSince : 0); }
+
+ /**
+ * Resumes playback
+ */
+ resume() {
+ this._pausedTime += Date.now() - this.pausedSince;
+ this.pausedSince = null;
+ if (this._writeCallback) this._writeCallback();
+ }
+
+ /**
+ * The time (in milliseconds) that the dispatcher has actually been playing audio for
+ * @type {number}
+ */
+ get streamTime() {
+ return this.count * FRAME_LENGTH;
+ }
+
+ /**
+ * The time (in milliseconds) that the dispatcher has been playing audio for, taking into account skips and pauses
* @type {number}
- * @readonly
*/
get totalStreamTime() {
- return this.time + this.streamingData.pausedTime;
+ return Date.now() - this.startTime;
}
/**
- * 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).
- */
- resume() { this.setPaused(false); }
-
-
- /**
- * Stops the current stream permanently and emits an `end` event.
- * @param {string} [reason='user'] An optional reason for stopping the dispatcher
- */
- end(reason = 'user') {
- this.destroy('end', reason);
- }
-
- setSpeaking(value) {
- if (this.speaking === value) return;
- if (this.player.voiceConnection.status !== VoiceStatus.CONNECTED) return;
- this.speaking = value;
- /**
- * Emitted when the dispatcher starts/stops speaking.
- * @event StreamDispatcher#speaking
- * @param {boolean} value Whether or not the dispatcher is speaking
- */
- this.emit('speaking', value);
- }
-
-
- /**
- * Sets the bitrate of the current Opus encoder.
- * @param {number} bitrate New bitrate, in kbps.
+ * Set the bitrate of the current Opus encoder if using a compatible Opus stream.
+ * @param {number} value New bitrate, in kbps
* If set to 'auto', the voice channel's bitrate will be used
+ * @returns {boolean} true if the bitrate has been successfully changed.
*/
- setBitrate(bitrate) {
- this.player.setBitrate(bitrate);
+ setBitrate(value) {
+ if (!value || !this.bitrateEditable) return false;
+ const bitrate = value === 'auto' ? this.player.voiceConnection.channel.bitrate : value;
+ this.streams.opus.setBitrate(bitrate * 1000);
+ return true;
}
- sendBuffer(buffer, sequence, timestamp, opusPacket) {
- opusPacket = opusPacket || this.player.opusEncoder.encode(buffer);
- const packet = this.createPacket(sequence, timestamp, opusPacket);
- this.sendPacket(packet);
+ /**
+ * Sets the expected packet loss percentage if using a compatible Opus stream.
+ * @param {number} value between 0 and 1
+ * @returns {boolean} Returns true if it was successfully set.
+ */
+ setPLP(value) {
+ if (!this.bitrateEditable) return false;
+ this.streams.opus.setPLP(value);
+ return true;
}
- sendPacket(packet) {
- let repeats = this.passes;
- /**
- * Emitted whenever the dispatcher has debug information.
- * @event StreamDispatcher#debug
- * @param {string} info The debug info
- */
- this.setSpeaking(true);
- while (repeats--) {
- this.player.voiceConnection.sockets.udp.send(packet)
- .catch(e => {
- this.setSpeaking(false);
- this.emit('debug', `Failed to send a packet ${e}`);
- });
+ /**
+ * Enables or disables forward error correction if using a compatible Opus stream.
+ * @param {boolean} enabled true to enable
+ * @returns {boolean} Returns true if it was successfully set.
+ */
+ setFEC(enabled) {
+ if (!this.bitrateEditable) return false;
+ this.streams.opus.setFEC(enabled);
+ return true;
+ }
+
+ _step(done) {
+ if (this.pausedSince) {
+ this._writeCallback = done;
+ return;
}
+ if (!this.streams.broadcast) {
+ const next = FRAME_LENGTH + (this.count * FRAME_LENGTH) - (Date.now() - this.startTime - this.pausedTime);
+ setTimeout(done.bind(this), next);
+ }
+ this._sdata.sequence++;
+ this._sdata.timestamp += TIMESTAMP_INC;
+ if (this._sdata.sequence >= 2 ** 16) this._sdata.sequence = 0;
+ if (this._sdata.timestamp >= 2 ** 32) this._sdata.timestamp = 0;
+ this.count++;
}
- createPacket(sequence, timestamp, buffer) {
+ _playChunk(chunk) {
+ if (this.player.dispatcher !== this || !this.player.voiceConnection.authentication.secretKey) return;
+ this._setSpeaking(true);
+ this._sendPacket(this._createPacket(this._sdata.sequence, this._sdata.timestamp, chunk));
+ }
+
+ _createPacket(sequence, timestamp, buffer) {
const packetBuffer = Buffer.alloc(buffer.length + 28);
packetBuffer.fill(0);
packetBuffer[0] = 0x80;
@@ -163,169 +226,69 @@ class StreamDispatcher extends VolumeInterface {
packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12);
- buffer = secretbox.methods.close(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key);
+ buffer = secretbox.methods.close(buffer, nonce, this.player.voiceConnection.authentication.secretKey);
for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i];
return packetBuffer;
}
- processPacket(packet) {
- try {
- if (this.destroyed) {
- this.setSpeaking(false);
- return;
- }
-
- const data = this.streamingData;
-
- if (this.paused) {
- this.setSpeaking(false);
- data.pausedTime = data.length * 10;
- return;
- }
-
- if (!packet) {
- data.missed++;
- data.pausedTime += data.length * 10;
- return;
- }
-
- this.started();
- this.missed = 0;
-
- this.stepStreamingData();
- this.sendBuffer(null, data.sequence, data.timestamp, packet);
- } catch (e) {
- this.destroy('error', e);
- }
- }
-
- process() {
- try {
- if (this.destroyed) {
- this.setSpeaking(false);
- return;
- }
-
- const data = this.streamingData;
-
- if (data.missed >= 5) {
- this.destroy('end', 'Stream is not generating quickly enough.');
- return;
- }
-
- if (this.paused) {
- this.setSpeaking(false);
- // Old code?
- // data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
- data.pausedTime += data.length * 10;
- this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10);
- return;
- }
-
- this.started();
-
- const buffer = this.readStreamBuffer();
- if (!buffer) {
- data.missed++;
- data.pausedTime += data.length * 10;
- this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), data.length * 10);
- return;
- }
-
- data.missed = 0;
-
- this.stepStreamingData();
-
- if (this._opus) {
- this.sendBuffer(null, data.sequence, data.timestamp, buffer);
- } else {
- this.sendBuffer(buffer, data.sequence, data.timestamp);
- }
-
- const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now());
- this.player.voiceConnection.voiceManager.client.setTimeout(() => this.process(), nextTime);
- } catch (e) {
- this.destroy('error', e);
- }
- }
-
- readStreamBuffer() {
- const data = this.streamingData;
- const bufferLength = (this._opus ? 80 : 1920) * data.channels;
- let buffer = this.stream.read(bufferLength);
- if (this._opus) return buffer;
- if (!buffer) return null;
-
- if (buffer.length !== bufferLength) {
- const newBuffer = Buffer.alloc(bufferLength).fill(0);
- buffer.copy(newBuffer);
- buffer = newBuffer;
- }
-
- buffer = this.applyVolume(buffer);
- return buffer;
- }
-
- started() {
- const data = this.streamingData;
-
- if (!data.startTime) {
- /**
- * Emitted once the dispatcher starts streaming.
- * @event StreamDispatcher#start
- */
- this.emit('start');
- data.startTime = Date.now();
- }
- }
-
- stepStreamingData() {
- const data = this.streamingData;
- data.count++;
- data.sequence = data.sequence < 65535 ? data.sequence + 1 : 0;
- data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
- }
-
- destroy(type, reason) {
- if (this.destroyed) return;
- this.destroyed = true;
- this.setSpeaking(false);
- this.emit(type, reason);
+ _sendPacket(packet) {
+ let repeats = this.streamOptions.passes;
/**
- * Emitted once the dispatcher ends.
- * @param {string} [reason] The reason the dispatcher ended
- * @event StreamDispatcher#end
+ * Emitted whenever the dispatcher has debug information.
+ * @event StreamDispatcher#debug
+ * @param {string} info The debug info
*/
- if (type !== 'end') this.emit('end', `destroyed due to ${type} - ${reason}`);
- }
-
- startStreaming() {
- if (!this.stream) {
- /**
- * Emitted if the dispatcher encounters an error.
- * @event StreamDispatcher#error
- * @param {string} error The error message
- */
- this.emit('error', 'No stream');
- return;
+ this._setSpeaking(true);
+ while (repeats--) {
+ this.player.voiceConnection.sockets.udp.send(packet)
+ .catch(e => {
+ this._setSpeaking(false);
+ this.emit('debug', `Failed to send a packet ${e}`);
+ });
}
-
- this.stream.on('end', err => this.destroy('end', err || 'stream'));
- this.stream.on('error', err => this.destroy('error', err));
-
- const data = this.streamingData;
- data.length = 20;
- data.missed = 0;
-
- this.stream.once('readable', () => {
- data.startTime = null;
- data.count = 0;
- this.process();
- });
}
- setPaused(paused) { this.setSpeaking(!(this.paused = paused)); }
+ _setSpeaking(value) {
+ if (this.speaking === value) return;
+ if (this.player.voiceConnection.status !== VoiceStatus.CONNECTED) return;
+ this.speaking = value;
+ this.player.voiceConnection.setSpeaking(value);
+ /**
+ * Emitted when the dispatcher starts/stops speaking.
+ * @event StreamDispatcher#speaking
+ * @param {boolean} value Whether or not the dispatcher is speaking
+ */
+ this.emit('speaking', value);
+ }
+
+ get volumeEditable() { return Boolean(this.streams.volume); }
+
+ /**
+ * Whether or not the Opus bitrate of this stream is editable
+ * @type {boolean}
+ */
+ get bitrateEditable() { return this.streams.opus && this.streams.opus.setBitrate; }
+
+ // Volume
+ get volume() {
+ return this.streams.volume ? this.streams.volume.volume : 1;
+ }
+
+ setVolume(value) {
+ if (!this.streams.volume) return false;
+ this.streams.volume.setVolume(value);
+ return true;
+ }
+
+ // Volume stubs for docs
+ /* eslint-disable no-empty-function*/
+ get volumeDecibels() {}
+ get volumeLogarithmic() {}
+ setVolumeDecibels() {}
+ setVolumeLogarithmic() {}
}
+VolumeInterface.applyToClass(StreamDispatcher);
+
module.exports = StreamDispatcher;
diff --git a/src/client/voice/VoiceUDPClient.js b/src/client/voice/networking/VoiceUDPClient.js
similarity index 97%
rename from src/client/voice/VoiceUDPClient.js
rename to src/client/voice/networking/VoiceUDPClient.js
index 813d3a34b..38dd389f3 100644
--- a/src/client/voice/VoiceUDPClient.js
+++ b/src/client/voice/networking/VoiceUDPClient.js
@@ -1,8 +1,8 @@
const udp = require('dgram');
const dns = require('dns');
-const { VoiceOPCodes } = require('../../util/Constants');
+const { VoiceOPCodes } = require('../../../util/Constants');
const EventEmitter = require('events');
-const { Error } = require('../../errors');
+const { Error } = require('../../../errors');
/**
* Represents a UDP client for a Voice Connection.
diff --git a/src/client/voice/VoiceWebSocket.js b/src/client/voice/networking/VoiceWebSocket.js
similarity index 90%
rename from src/client/voice/VoiceWebSocket.js
rename to src/client/voice/networking/VoiceWebSocket.js
index a34962496..19c0a2127 100644
--- a/src/client/voice/VoiceWebSocket.js
+++ b/src/client/voice/networking/VoiceWebSocket.js
@@ -1,8 +1,7 @@
-const { OPCodes, VoiceOPCodes } = require('../../util/Constants');
-const SecretKey = require('./util/SecretKey');
+const { OPCodes, VoiceOPCodes } = require('../../../util/Constants');
const EventEmitter = require('events');
-const { Error } = require('../../errors');
-const WebSocket = require('../../WebSocket');
+const { Error } = require('../../../errors');
+const WebSocket = require('../../../WebSocket');
/**
* Represents a Voice Connection's WebSocket.
@@ -156,7 +155,8 @@ class VoiceWebSocket extends EventEmitter {
onPacket(packet) {
switch (packet.op) {
case VoiceOPCodes.READY:
- this.setHeartbeat(packet.d.heartbeat_interval);
+ // *.75 to correct for discord devs taking longer to fix things than i do to release versions
+ this.setHeartbeat(packet.d.heartbeat_interval * 0.75);
/**
* Emitted once the voice WebSocket receives the ready packet.
* @param {Object} packet The received packet
@@ -164,14 +164,17 @@ class VoiceWebSocket extends EventEmitter {
*/
this.emit('ready', packet.d);
break;
+ /* eslint-disable no-case-declarations */
case VoiceOPCodes.SESSION_DESCRIPTION:
+ const key = new Uint8Array(new ArrayBuffer(packet.d.secret_key.length));
+ for (const i in packet.d.secret_key) key[i] = packet.d.secret_key[i];
/**
* Emitted once the Voice Websocket receives a description of this voice session.
* @param {string} encryptionMode The type of encryption being used
- * @param {SecretKey} secretKey The secret key used for encryption
+ * @param {Uint8Array} secretKey The secret key used for encryption
* @event VoiceWebSocket#sessionDescription
*/
- this.emit('sessionDescription', packet.d.mode, new SecretKey(packet.d.secret_key));
+ this.emit('sessionDescription', packet.d.mode, key);
break;
case VoiceOPCodes.SPEAKING:
/**
diff --git a/src/client/voice/opus/BaseOpusEngine.js b/src/client/voice/opus/BaseOpusEngine.js
deleted file mode 100644
index a51044905..000000000
--- a/src/client/voice/opus/BaseOpusEngine.js
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * The base opus encoding engine.
- * @private
- */
-class BaseOpus {
- /**
- * @param {Object} [options] The options to apply to the Opus engine
- * @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({ bitrate = 48, fec = false, plp = 0 } = {}) {
- this.ctl = {
- BITRATE: 4002,
- FEC: 4012,
- PLP: 4014,
- };
-
- 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);
-
- // Set PLP (expected packet loss percentage)
- if (this.options.plp) this.setPLP(this.options.plp);
- } catch (err) {
- // Opus engine likely has no support for libopus CTL
- }
- }
-
- encode(buffer) {
- return buffer;
- }
-
- decode(buffer) {
- return buffer;
- }
-
- destroy() {} // eslint-disable-line no-empty-function
-}
-
-module.exports = BaseOpus;
diff --git a/src/client/voice/opus/NodeOpusEngine.js b/src/client/voice/opus/NodeOpusEngine.js
deleted file mode 100644
index 02e880637..000000000
--- a/src/client/voice/opus/NodeOpusEngine.js
+++ /dev/null
@@ -1,40 +0,0 @@
-const OpusEngine = require('./BaseOpusEngine');
-
-let opus;
-
-class NodeOpusEngine extends OpusEngine {
- constructor(player) {
- super(player);
- try {
- opus = require('node-opus');
- } catch (err) {
- throw err;
- }
- 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);
- }
-
- setPLP(percent) {
- this.encoder.applyEncoderCTL(this.ctl.PLP, Math.min(100, Math.max(0, percent * 100)));
- }
-
- encode(buffer) {
- super.encode(buffer);
- return this.encoder.encode(buffer, 1920);
- }
-
- decode(buffer) {
- super.decode(buffer);
- return this.encoder.decode(buffer, 1920);
- }
-}
-
-module.exports = NodeOpusEngine;
diff --git a/src/client/voice/opus/OpusEngineList.js b/src/client/voice/opus/OpusEngineList.js
deleted file mode 100644
index 01e3ff6d1..000000000
--- a/src/client/voice/opus/OpusEngineList.js
+++ /dev/null
@@ -1,30 +0,0 @@
-const { Error } = require('../../../errors');
-
-const list = [
- require('./NodeOpusEngine'),
- require('./OpusScriptEngine'),
-];
-
-function fetch(Encoder, engineOptions) {
- try {
- return new Encoder(engineOptions);
- } catch (err) {
- if (err.code === 'MODULE_NOT_FOUND') return null;
-
- // The Opus engine exists, but another error occurred.
- throw err;
- }
-}
-
-exports.add = encoder => {
- list.push(encoder);
-};
-
-exports.fetch = engineOptions => {
- for (const encoder of list) {
- const fetched = fetch(encoder, engineOptions);
- if (fetched) return fetched;
- }
-
- throw new Error('OPUS_ENGINE_MISSING');
-};
diff --git a/src/client/voice/opus/OpusScriptEngine.js b/src/client/voice/opus/OpusScriptEngine.js
deleted file mode 100644
index a5e046d40..000000000
--- a/src/client/voice/opus/OpusScriptEngine.js
+++ /dev/null
@@ -1,45 +0,0 @@
-const OpusEngine = require('./BaseOpusEngine');
-
-let OpusScript;
-
-class OpusScriptEngine extends OpusEngine {
- constructor(player) {
- super(player);
- try {
- OpusScript = require('opusscript');
- } catch (err) {
- throw err;
- }
- 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);
- }
-
- setPLP(percent) {
- this.encoder.encoderCTL(this.ctl.PLP, Math.min(100, Math.max(0, percent * 100)));
- }
-
- encode(buffer) {
- super.encode(buffer);
- return this.encoder.encode(buffer, 960);
- }
-
- decode(buffer) {
- super.decode(buffer);
- return this.encoder.decode(buffer);
- }
-
- destroy() {
- super.destroy();
- this.encoder.delete();
- }
-}
-
-module.exports = OpusScriptEngine;
diff --git a/src/client/voice/player/AudioPlayer.js b/src/client/voice/player/AudioPlayer.js
index 5380de3f8..e3381f5cc 100644
--- a/src/client/voice/player/AudioPlayer.js
+++ b/src/client/voice/player/AudioPlayer.js
@@ -1,23 +1,11 @@
-const EventEmitter = require('events').EventEmitter;
-const Prism = require('prism-media');
-const StreamDispatcher = require('../dispatcher/StreamDispatcher');
-const Collection = require('../../../util/Collection');
-const OpusEncoders = require('../opus/OpusEngineList');
-
-const ffmpegArguments = [
- '-analyzeduration', '0',
- '-loglevel', '0',
- '-f', 's16le',
- '-ar', '48000',
- '-ac', '2',
-];
+const BasePlayer = require('./BasePlayer');
/**
* An Audio Player for a Voice Connection.
* @private
- * @extends {EventEmitter}
+ * @extends {BasePlayer}
*/
-class AudioPlayer extends EventEmitter {
+class AudioPlayer extends BasePlayer {
constructor(voiceConnection) {
super();
/**
@@ -25,145 +13,11 @@ class AudioPlayer extends EventEmitter {
* @type {VoiceConnection}
*/
this.voiceConnection = voiceConnection;
- /**
- * The prism transcoder that the player uses
- * @type {Prism}
- */
- this.prism = new Prism();
- this.streams = new Collection();
- this.currentStream = {};
- this.streamingData = {
- channels: 2,
- count: 0,
- sequence: 0,
- timestamp: 0,
- pausedTime: 0,
- };
- this.voiceConnection.once('closing', () => this.destroyCurrentStream());
- }
-
- /**
- * The current transcoder
- * @type {?Object}
- * @readonly
- */
- get transcoder() {
- return this.currentStream.transcoder;
- }
-
- /**
- * The current dispatcher
- * @type {?StreamDispatcher}
- * @readonly
- */
- get dispatcher() {
- return this.currentStream.dispatcher;
- }
-
- destroy() {
- if (this.opusEncoder) this.opusEncoder.destroy();
- this.opusEncoder = null;
- }
-
- destroyCurrentStream() {
- const transcoder = this.transcoder;
- const dispatcher = this.dispatcher;
- if (transcoder) transcoder.kill();
- if (dispatcher) {
- const end = dispatcher.listeners('end')[0];
- const error = dispatcher.listeners('error')[0];
- if (end) dispatcher.removeListener('end', end);
- if (error) dispatcher.removeListener('error', error);
- dispatcher.destroy('end');
- }
- this.currentStream = {};
- this.streamingData.pausedTime = 0;
- }
-
- /**
- * 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(options.seek || 0)]),
- });
- this.destroyCurrentStream();
- this.currentStream = {
- transcoder: transcoder,
- output: transcoder.output,
- input: stream,
- };
- transcoder.on('error', e => {
- this.destroyCurrentStream();
- if (this.listenerCount('error') > 0) this.emit('error', e);
- this.emit('warn', `prism transcoder error - ${e}`);
- });
- return this.playPCMStream(transcoder.output, options, true);
- }
-
- 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;
- } else {
- this.destroyCurrentStream();
- this.currentStream = {
- dispatcher,
- input: stream,
- output: stream,
- };
- }
- return dispatcher;
- }
-
- playOpusStream(stream, options = {}) {
- options.opus = true;
- this.destroyCurrentStream();
- const dispatcher = this.createDispatcher(stream, options);
- this.currentStream = {
- dispatcher,
- input: stream,
- output: stream,
- };
- return dispatcher;
}
playBroadcast(broadcast, options) {
- this.destroyCurrentStream();
- const dispatcher = this.createDispatcher(broadcast, options);
- this.currentStream = {
- dispatcher,
- broadcast,
- input: broadcast,
- output: broadcast,
- };
- broadcast.registerDispatcher(dispatcher);
- return dispatcher;
- }
-
- 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());
- dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value));
+ const dispatcher = this.createDispatcher(options, { broadcast });
+ broadcast.dispatchers.add(dispatcher);
return dispatcher;
}
}
diff --git a/src/client/voice/player/BasePlayer.js b/src/client/voice/player/BasePlayer.js
new file mode 100644
index 000000000..fac2b68be
--- /dev/null
+++ b/src/client/voice/player/BasePlayer.js
@@ -0,0 +1,84 @@
+const EventEmitter = require('events').EventEmitter;
+const { Readable: ReadableStream } = require('stream');
+const prism = require('prism-media');
+const StreamDispatcher = require('../dispatcher/StreamDispatcher');
+
+const FFMPEG_ARGUMENTS = [
+ '-analyzeduration', '0',
+ '-loglevel', '0',
+ '-f', 's16le',
+ '-ar', '48000',
+ '-ac', '2',
+];
+
+/**
+ * An Audio Player for a Voice Connection.
+ * @private
+ * @extends {EventEmitter}
+ */
+class BasePlayer extends EventEmitter {
+ constructor() {
+ super();
+
+ this.dispatcher = null;
+
+ this.streamingData = {
+ channels: 2,
+ sequence: 0,
+ timestamp: 0,
+ };
+ }
+
+ destroy() {
+ this.destroyDispatcher();
+ }
+
+ destroyDispatcher() {
+ if (this.dispatcher) {
+ this.dispatcher.destroy();
+ this.dispatcher = null;
+ }
+ }
+
+ playUnknown(input, options) {
+ this.destroyDispatcher();
+
+ const isStream = input instanceof ReadableStream;
+ const args = isStream ? FFMPEG_ARGUMENTS : ['-i', input, ...FFMPEG_ARGUMENTS];
+ const ffmpeg = new prism.FFmpeg({ args });
+ const streams = { ffmpeg };
+ if (isStream) {
+ streams.input = input;
+ input.pipe(ffmpeg);
+ }
+ return this.playPCMStream(ffmpeg, options, streams);
+ }
+
+ playPCMStream(stream, options, streams = {}) {
+ this.destroyDispatcher();
+ const opus = streams.opus = new prism.opus.Encoder({ channels: 2, rate: 48000, frameSize: 1920 });
+ if (options && options.volume === false) {
+ stream.pipe(opus);
+ return this.playOpusStream(opus, options, streams);
+ }
+ const volume = streams.volume = new prism.VolumeTransformer16LE(null, { volume: options ? options.volume : 1 });
+ stream.pipe(volume).pipe(opus);
+ return this.playOpusStream(opus, options, streams);
+ }
+
+ playOpusStream(stream, options, streams = {}) {
+ this.destroyDispatcher();
+ streams.opus = stream;
+ const dispatcher = this.createDispatcher(options, streams);
+ stream.pipe(dispatcher);
+ return dispatcher;
+ }
+
+ createDispatcher(options, streams, broadcast) {
+ this.destroyDispatcher();
+ const dispatcher = this.dispatcher = new StreamDispatcher(this, options, streams, broadcast);
+ return dispatcher;
+ }
+}
+
+module.exports = BasePlayer;
diff --git a/src/client/voice/player/BroadcastAudioPlayer.js b/src/client/voice/player/BroadcastAudioPlayer.js
new file mode 100644
index 000000000..052c9ea0b
--- /dev/null
+++ b/src/client/voice/player/BroadcastAudioPlayer.js
@@ -0,0 +1,26 @@
+const BroadcastDispatcher = require('../dispatcher/BroadcastDispatcher');
+const BasePlayer = require('./BasePlayer');
+
+/**
+ * An Audio Player for a Voice Connection.
+ * @private
+ * @extends {BasePlayer}
+ */
+class AudioPlayer extends BasePlayer {
+ constructor(broadcast) {
+ super();
+ /**
+ * The broadcast that the player serves
+ * @type {VoiceBroadcast}
+ */
+ this.broadcast = broadcast;
+ }
+
+ createDispatcher(options, streams) {
+ this.destroyDispatcher();
+ const dispatcher = this.dispatcher = new BroadcastDispatcher(this, options, streams);
+ return dispatcher;
+ }
+}
+
+module.exports = AudioPlayer;
diff --git a/src/client/voice/receiver/PacketHandler.js b/src/client/voice/receiver/PacketHandler.js
new file mode 100644
index 000000000..9ebe69d85
--- /dev/null
+++ b/src/client/voice/receiver/PacketHandler.js
@@ -0,0 +1,63 @@
+const nonce = Buffer.alloc(24);
+const secretbox = require('../util/Secretbox');
+const EventEmitter = require('events');
+
+class Readable extends require('stream').Readable { _read() {} } // eslint-disable-line no-empty-function
+
+class PacketHandler extends EventEmitter {
+ constructor(receiver) {
+ super();
+ this.receiver = receiver;
+ this.streams = new Map();
+ }
+
+ makeStream(user) {
+ if (this.streams.has(user)) return this.streams.get(user);
+ const stream = new Readable();
+ this.streams.set(user, stream);
+ return stream;
+ }
+
+ parseBuffer(buffer) {
+ // Reuse nonce buffer
+ buffer.copy(nonce, 0, 0, 12);
+
+ let packet = secretbox.methods.open(buffer.slice(12), nonce, this.receiver.connection.authentication.secretKey);
+ if (!packet) return new Error('Failed to decrypt voice packet');
+ packet = Buffer.from(packet);
+
+ // Strip RTP Header Extensions (one-byte only)
+ if (packet[0] === 0xBE && packet[1] === 0xDE && packet.length > 4) {
+ const headerExtensionLength = packet.readUInt16BE(2);
+ let offset = 4;
+ for (let i = 0; i < headerExtensionLength; i++) {
+ const byte = packet[offset];
+ offset++;
+ if (byte === 0) continue;
+ offset += 1 + (0b1111 & (byte >> 4));
+ }
+ while (packet[offset] === 0) offset++;
+ packet = packet.slice(offset);
+ }
+
+ return packet;
+ }
+
+ userFromSSRC(ssrc) { return this.receiver.connection.ssrcMap.get(ssrc); }
+
+ push(buffer) {
+ const ssrc = buffer.readUInt32BE(8);
+ const user = this.userFromSSRC(ssrc);
+ if (!user) return;
+ const stream = this.streams.get(user.id);
+ if (!stream) return;
+ const opusPacket = this.parseBuffer(buffer);
+ if (opusPacket instanceof Error) {
+ this.emit('error', opusPacket);
+ return;
+ }
+ stream.push(opusPacket);
+ }
+}
+
+module.exports = PacketHandler;
diff --git a/src/client/voice/receiver/Receiver.js b/src/client/voice/receiver/Receiver.js
new file mode 100644
index 000000000..9636377d7
--- /dev/null
+++ b/src/client/voice/receiver/Receiver.js
@@ -0,0 +1,55 @@
+const EventEmitter = require('events');
+const prism = require('prism-media');
+const PacketHandler = require('./PacketHandler');
+const { Error } = require('../../../errors');
+
+/**
+ * Receives audio packets from a voice connection.
+ * @example
+ * const receiver = connection.createReceiver();
+ * // opusStream is a ReadableStream - that means you could play it back to a voice channel if you wanted to!
+ * const opusStream = receiver.createStream(user);
+ */
+class VoiceReceiver extends EventEmitter {
+ constructor(connection) {
+ super();
+ this.connection = connection;
+ this.packets = new PacketHandler(this);
+ /**
+ * Emitted whenever there is a warning
+ * @event VoiceReceiver#debug
+ * @param {Error|string} error The error or message to debug
+ */
+ this.packets.on('error', err => this.emit('debug', err));
+ this.connection.sockets.udp.socket.on('message', buffer => this.packets.push(buffer));
+ }
+
+ /**
+ * Options passed to `VoiceReceiver#createStream`.
+ * @typedef {Object} ReceiveStreamOptions
+ * @property {string} [mode='opus'] The mode for audio output. This defaults to opus, meaning discord.js won't decode
+ * the packets for you. You can set this to 'pcm' so that the stream's output will be 16-bit little-endian stereo
+ * audio
+ */
+
+ /**
+ * Creates a new audio receiving stream. If a stream already exists for a user, then that stream will be returned
+ * rather than generating a new one.
+ * @param {UserResolvable} user The user to start listening to.
+ * @param {ReceiveStreamOptions} options Options.
+ * @returns {ReadableStream}
+ */
+ createStream(user, { mode = 'opus' } = {}) {
+ user = this.connection.client.users.resolve(user);
+ if (!user) throw new Error('VOICE_USER_MISSING');
+ const stream = this.packets.makeStream(user.id);
+ if (mode === 'pcm') {
+ const decoder = new prism.opus.Decoder({ channels: 2, rate: 48000, frameSize: 1920 });
+ stream.pipe(decoder);
+ return decoder;
+ }
+ return stream;
+ }
+}
+
+module.exports = VoiceReceiver;
diff --git a/src/client/voice/receiver/VoiceReadable.js b/src/client/voice/receiver/VoiceReadable.js
deleted file mode 100644
index b29d37aad..000000000
--- a/src/client/voice/receiver/VoiceReadable.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const { Readable } = require('stream');
-
-class VoiceReadable extends Readable {
- constructor() {
- super();
- this._packets = [];
- this.open = true;
- }
-
- _read() {} // eslint-disable-line no-empty-function
-
- _push(d) {
- if (this.open) this.push(d);
- }
-}
-
-module.exports = VoiceReadable;
diff --git a/src/client/voice/receiver/VoiceReceiver.js b/src/client/voice/receiver/VoiceReceiver.js
deleted file mode 100644
index 4c889865c..000000000
--- a/src/client/voice/receiver/VoiceReceiver.js
+++ /dev/null
@@ -1,220 +0,0 @@
-const EventEmitter = require('events');
-const secretbox = require('../util/Secretbox');
-const Readable = require('./VoiceReadable');
-const OpusEncoders = require('../opus/OpusEngineList');
-const { Error } = require('../../../errors');
-
-const nonce = Buffer.alloc(24);
-nonce.fill(0);
-
-/**
- * Receives voice data from a voice connection.
- * ```js
- * // Obtained using:
- * voiceChannel.join()
- * .then(connection => {
- * const receiver = connection.createReceiver();
- * });
- * ```
- * @extends {EventEmitter}
- */
-class VoiceReceiver extends EventEmitter {
- constructor(connection) {
- super();
- /*
- Need a queue because we don't get the ssrc of the user speaking until after the first few packets,
- so we queue up unknown SSRCs until they become known, then empty the queue
- */
- this.queues = new Map();
- this.pcmStreams = new Map();
- this.opusStreams = new Map();
- this.opusEncoders = new Map();
-
- /**
- * Whether or not this receiver has been destroyed
- * @type {boolean}
- */
- this.destroyed = false;
-
- /**
- * The VoiceConnection that instantiated this
- * @type {VoiceConnection}
- */
- this.voiceConnection = connection;
-
- this._listener = msg => {
- const ssrc = +msg.readUInt32BE(8).toString(10);
- const user = this.voiceConnection.ssrcMap.get(ssrc);
- if (!user) {
- if (!this.queues.has(ssrc)) this.queues.set(ssrc, []);
- this.queues.get(ssrc).push(msg);
- } else {
- if (this.queues.get(ssrc)) {
- this.queues.get(ssrc).push(msg);
- this.queues.get(ssrc).map(m => this.handlePacket(m, user));
- this.queues.delete(ssrc);
- return;
- }
- this.handlePacket(msg, user);
- }
- };
- this.voiceConnection.sockets.udp.socket.on('message', this._listener);
- }
-
- /**
- * If this VoiceReceiver has been destroyed, running `recreate()` will recreate the listener.
- * This avoids you having to create a new receiver.
- * Any streams that you had prior to destroying the receiver will not be recreated.
- */
- recreate() {
- if (!this.destroyed) return;
- this.voiceConnection.sockets.udp.socket.on('message', this._listener);
- this.destroyed = false;
- }
-
- /**
- * Destroys this VoiceReceiver, also ending any streams that it may be controlling.
- */
- destroy() {
- this.voiceConnection.sockets.udp.socket.removeListener('message', this._listener);
- for (const [id, stream] of this.pcmStreams) {
- stream._push(null);
- this.pcmStreams.delete(id);
- }
- for (const [id, stream] of this.opusStreams) {
- stream._push(null);
- this.opusStreams.delete(id);
- }
- for (const [id, encoder] of this.opusEncoders) {
- encoder.destroy();
- this.opusEncoders.delete(id);
- }
- this.destroyed = true;
- }
-
- /**
- * Invoked when a user stops speaking.
- * @param {User} user The user that stopped speaking
- * @private
- */
- stoppedSpeaking(user) {
- const opusStream = this.opusStreams.get(user.id);
- const pcmStream = this.pcmStreams.get(user.id);
- const opusEncoder = this.opusEncoders.get(user.id);
- if (opusStream) {
- opusStream.push(null);
- opusStream.open = false;
- this.opusStreams.delete(user.id);
- }
- if (pcmStream) {
- pcmStream.push(null);
- pcmStream.open = false;
- this.pcmStreams.delete(user.id);
- }
- if (opusEncoder) {
- opusEncoder.destroy();
- }
- }
-
- /**
- * Creates a readable stream for a user that provides opus data while the user is speaking. When the user
- * stops speaking, the stream is destroyed.
- * @param {UserResolvable} user The user to create the stream for
- * @returns {ReadableStream}
- */
- createOpusStream(user) {
- user = this.voiceConnection.voiceManager.client.users.resolve(user);
- if (!user) throw new Error('VOICE_USER_MISSING');
- if (this.opusStreams.get(user.id)) throw new Error('VOICE_STREAM_EXISTS');
- const stream = new Readable();
- this.opusStreams.set(user.id, stream);
- return stream;
- }
-
- /**
- * Creates a readable stream for a user that provides PCM data while the user is speaking. When the user
- * stops speaking, the stream is destroyed. The stream is 32-bit signed stereo PCM at 48KHz.
- * @param {UserResolvable} user The user to create the stream for
- * @returns {ReadableStream}
- */
- createPCMStream(user) {
- user = this.voiceConnection.voiceManager.client.users.resolve(user);
- if (!user) throw new Error('VOICE_USER_MISSING');
- if (this.pcmStreams.get(user.id)) throw new Error('VOICE_STREAM_EXISTS');
- const stream = new Readable();
- this.pcmStreams.set(user.id, stream);
- return stream;
- }
-
- handlePacket(msg, user) {
- msg.copy(nonce, 0, 0, 12);
- let data = secretbox.methods.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key);
- if (!data) {
- /**
- * Emitted whenever a voice packet experiences a problem.
- * @event VoiceReceiver#warn
- * @param {string} reason The reason for the warning. If it happened because the voice packet could not be
- * decrypted, this would be `decrypt`. If it happened because the voice packet could not be decoded into
- * PCM, this would be `decode`
- * @param {string} message The warning message
- */
- this.emit('warn', 'decrypt', 'Failed to decrypt voice packet');
- return;
- }
- data = Buffer.from(data);
-
- // Strip RTP Header Extensions (one-byte only)
- if (data[0] === 0xBE && data[1] === 0xDE && data.length > 4) {
- const headerExtensionLength = data.readUInt16BE(2);
- let offset = 4;
- for (let i = 0; i < headerExtensionLength; i++) {
- const byte = data[offset];
- offset++;
- if (byte === 0) {
- continue;
- }
- offset += 1 + (0b1111 & (byte >> 4));
- }
- while (data[offset] === 0) {
- offset++;
- }
- data = data.slice(offset);
- }
-
- if (this.opusStreams.get(user.id)) this.opusStreams.get(user.id)._push(data);
- /**
- * Emitted whenever voice data is received from the voice connection. This is _always_ emitted (unlike PCM).
- * @event VoiceReceiver#opus
- * @param {User} user The user that is sending the buffer (is speaking)
- * @param {Buffer} buffer The opus buffer
- */
- this.emit('opus', user, data);
- if (this.listenerCount('pcm') > 0 || this.pcmStreams.size > 0) {
- if (!this.opusEncoders.get(user.id)) this.opusEncoders.set(user.id, OpusEncoders.fetch());
- const { pcm, error } = VoiceReceiver._tryDecode(this.opusEncoders.get(user.id), data);
- if (error) {
- this.emit('warn', 'decode', `Failed to decode packet voice to PCM because: ${error.message}`);
- return;
- }
- if (this.pcmStreams.get(user.id)) this.pcmStreams.get(user.id)._push(pcm);
- /**
- * Emits decoded voice data when it's received. For performance reasons, the decoding will only
- * happen if there is at least one `pcm` listener on this receiver.
- * @event VoiceReceiver#pcm
- * @param {User} user The user that is sending the buffer (is speaking)
- * @param {Buffer} buffer The decoded buffer
- */
- this.emit('pcm', user, pcm);
- }
- }
-
- static _tryDecode(encoder, data) {
- try {
- return { pcm: encoder.decode(data) };
- } catch (error) {
- return { error };
- }
- }
-}
-
-module.exports = VoiceReceiver;
diff --git a/src/client/voice/util/DispatcherSet.js b/src/client/voice/util/DispatcherSet.js
new file mode 100644
index 000000000..a1ab7e943
--- /dev/null
+++ b/src/client/voice/util/DispatcherSet.js
@@ -0,0 +1,40 @@
+const { Events } = require('../../../util/Constants');
+
+/**
+ * A "store" for handling broadcast dispatcher (un)subscription
+ * @private
+ */
+class DispatcherSet extends Set {
+ constructor(broadcast) {
+ super();
+ /**
+ * The broadcast that this set belongs to
+ * @type {VoiceBroadcast}
+ */
+ this.broadcast = broadcast;
+ }
+
+ add(dispatcher) {
+ super.add(dispatcher);
+ /**
+ * Emitted whenever a stream dispatcher subscribes to the broadcast.
+ * @event VoiceBroadcast#subscribe
+ * @param {StreamDispatcher} dispatcher The subscribed dispatcher
+ */
+ this.broadcast.emit(Events.VOICE_BROADCAST_SUBSCRIBE, dispatcher);
+ return this;
+ }
+
+ delete(dispatcher) {
+ const ret = super.delete(dispatcher);
+ /**
+ * Emitted whenever a stream dispatcher unsubscribes to the broadcast.
+ * @event VoiceBroadcast#unsubscribe
+ * @param {StreamDispatcher} dispatcher The unsubscribed dispatcher
+ */
+ if (ret) this.broadcast.emit(Events.VOICE_BROADCAST_UNSUBSCRIBE, dispatcher);
+ return ret;
+ }
+}
+
+module.exports = DispatcherSet;
diff --git a/src/client/voice/util/PlayInterface.js b/src/client/voice/util/PlayInterface.js
new file mode 100644
index 000000000..ebcb1378e
--- /dev/null
+++ b/src/client/voice/util/PlayInterface.js
@@ -0,0 +1,93 @@
+const { Readable } = require('stream');
+const prism = require('prism-media');
+const { Error } = require('../../../errors');
+
+/**
+ * Options that can be passed to stream-playing methods:
+ * @typedef {Object} StreamOptions
+ * @property {StreamType} [type='unknown'] The type of stream.
+ * @property {number} [seek=0] The time to seek to
+ * @property {number|boolean} [volume=1] The volume to play at. Set this to false to disable volume transforms for
+ * this stream to improve performance.
+ * @property {number} [passes=1] How many times to send the voice packet to reduce packet loss
+ * @property {number} [plp] Expected packet loss percentage
+ * @property {boolean} [fec] Enabled forward error correction
+ * @property {number|string} [bitrate=96] The bitrate (quality) of the audio in kbps.
+ * If set to 'auto', the voice channel's bitrate will be used
+ * @property {number} [highWaterMark=12] The maximum number of opus packets to make and store before they are
+ * actually needed. See https://nodejs.org/en/docs/guides/backpressuring-in-streams/. Setting this value to
+ * 1 means that changes in volume will be more instant.
+ */
+
+/**
+ * An option passed as part of `StreamOptions` specifying the type of the stream.
+ * * `unknown`: The default type, streams/input will be passed through to ffmpeg before encoding.
+ * Will play most streams.
+ * * `converted`: Play a stream of 16bit signed stereo PCM data, skipping ffmpeg.
+ * * `opus`: Play a stream of opus packets, skipping ffmpeg. You lose the ability to alter volume.
+ * * `ogg/opus`: Play an ogg file with the opus encoding, skipping ffmpeg. You lose the ability to alter volume.
+ * * `webm/opus`: Play a webm file with opus audio, skipping ffmpeg. You lose the ability to alter volume.
+ * @typedef {string} StreamType
+ */
+
+/**
+ * An interface class to allow you to play audio over VoiceConnections and VoiceBroadcasts.
+ */
+class PlayInterface {
+ constructor(player) {
+ this.player = player;
+ }
+
+ /**
+ * Play an audio resource.
+ * @param {VoiceBroadcast|ReadableStream|string} resource The resource to play.
+ * @param {StreamOptions} [options] The options to play.
+ * @example
+ * // Play a local audio file
+ * connection.play('/home/hydrabolt/audio.mp3', { volume: 0.5 });
+ * @example
+ * // Play a ReadableStream
+ * connection.play(ytdl('https://www.youtube.com/watch?v=ZlAU_w7-Xp8', { filter: 'audioonly' }));
+ * @example
+ * // Play a voice broadcast
+ * const broadcast = client.createVoiceBroadcast();
+ * broadcast.play('/home/hydrabolt/audio.mp3');
+ * connection.play(broadcast);
+ * @example
+ * // Using different protocols: https://ffmpeg.org/ffmpeg-protocols.html
+ * connection.play('http://www.sample-videos.com/audio/mp3/wave.mp3');
+ * @returns {StreamDispatcher}
+ */
+ play(resource, options = {}) {
+ if (resource instanceof Broadcast) {
+ if (!this.player.playBroadcast) throw new Error('VOICE_PLAY_INTERFACE_NO_BROADCAST');
+ return this.player.playBroadcast(resource, options);
+ }
+ const type = options.type || 'unknown';
+ if (type === 'unknown') {
+ return this.player.playUnknown(resource, options);
+ } else if (type === 'converted') {
+ return this.player.playPCMStream(resource, options);
+ } else if (type === 'opus') {
+ return this.player.playOpusStream(resource, options);
+ } else if (type === 'ogg/opus') {
+ if (!(resource instanceof Readable)) throw new Error('VOICE_PRISM_DEMUXERS_NEED_STREAM');
+ return this.player.playOpusStream(resource.pipe(new prism.OggOpusDemuxer()));
+ } else if (type === 'webm/opus') {
+ if (!(resource instanceof Readable)) throw new Error('VOICE_PRISM_DEMUXERS_NEED_STREAM');
+ return this.player.playOpusStream(resource.pipe(new prism.WebmOpusDemuxer()));
+ }
+ throw new Error('VOICE_PLAY_INTERFACE_BAD_TYPE');
+ }
+
+ static applyToClass(structure) {
+ for (const prop of ['play']) {
+ Object.defineProperty(structure.prototype, prop,
+ Object.getOwnPropertyDescriptor(PlayInterface.prototype, prop));
+ }
+ }
+}
+
+module.exports = PlayInterface;
+
+const Broadcast = require('../VoiceBroadcast');
diff --git a/src/client/voice/util/SecretKey.js b/src/client/voice/util/SecretKey.js
deleted file mode 100644
index f165e5ffc..000000000
--- a/src/client/voice/util/SecretKey.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/**
- * Represents a Secret Key used in encryption over voice.
- * @private
- */
-class SecretKey {
- constructor(key) {
- /**
- * The key used for encryption
- * @type {Uint8Array}
- */
- this.key = new Uint8Array(new ArrayBuffer(key.length));
- for (const index in key) this.key[index] = key[index];
- }
-}
-
-module.exports = SecretKey;
diff --git a/src/client/voice/util/VolumeInterface.js b/src/client/voice/util/VolumeInterface.js
index 7ecd28e64..f3bafe1bb 100644
--- a/src/client/voice/util/VolumeInterface.js
+++ b/src/client/voice/util/VolumeInterface.js
@@ -5,13 +5,21 @@ const EventEmitter = require('events');
* @extends {EventEmitter}
*/
class VolumeInterface extends EventEmitter {
- constructor({ volume = 0 } = {}) {
+ constructor({ volume = 1 } = {}) {
super();
- this.setVolume(volume || 1);
+ this.setVolume(volume);
}
/**
- * The current volume of the broadcast
+ * Whether or not the volume of this stream is editable
+ * @type {boolean}
+ */
+ get volumeEditable() {
+ return true;
+ }
+
+ /**
+ * The current volume of the stream
* @readonly
* @type {number}
*/
@@ -20,21 +28,21 @@ class VolumeInterface extends EventEmitter {
}
/**
- * The current volume of the broadcast in decibels
+ * The current volume of the stream in decibels
* @readonly
* @type {number}
*/
get volumeDecibels() {
- return Math.log10(this._volume) * 20;
+ return Math.log10(this.volume) * 20;
}
/**
- * The current volume of the broadcast from a logarithmic scale
+ * The current volume of the stream from a logarithmic scale
* @readonly
* @type {number}
*/
get volumeLogarithmic() {
- return Math.pow(this._volume, 1 / 1.660964);
+ return Math.pow(this.volume, 1 / 1.660964);
}
applyVolume(buffer, volume) {
@@ -83,4 +91,19 @@ class VolumeInterface extends EventEmitter {
}
}
-module.exports = VolumeInterface;
+const props = [
+ 'volumeDecibels',
+ 'volumeLogarithmic',
+ 'setVolumeDecibels',
+ 'setVolumeLogarithmic',
+];
+
+exports.applyToClass = function applyToClass(structure) {
+ for (const prop of props) {
+ Object.defineProperty(
+ structure.prototype,
+ prop,
+ Object.getOwnPropertyDescriptor(VolumeInterface.prototype, prop)
+ );
+ }
+};
diff --git a/src/client/websocket/WebSocketConnection.js b/src/client/websocket/WebSocketConnection.js
index 4e3835ef0..5e07b1a69 100644
--- a/src/client/websocket/WebSocketConnection.js
+++ b/src/client/websocket/WebSocketConnection.js
@@ -269,13 +269,15 @@ class WebSocketConnection extends EventEmitter {
this.inflate.push(data, flush && zlib.Z_SYNC_FLUSH);
if (!flush) return;
+ let packet;
try {
- const packet = WebSocket.unpack(this.inflate.result);
- this.onPacket(packet);
- if (this.client.listenerCount('raw')) this.client.emit('raw', packet);
+ packet = WebSocket.unpack(this.inflate.result);
} catch (err) {
this.client.emit('debug', err);
+ return;
}
+ this.onPacket(packet);
+ if (this.client.listenerCount('raw')) this.client.emit('raw', packet);
}
/**
diff --git a/src/client/websocket/packets/handlers/GuildCreate.js b/src/client/websocket/packets/handlers/GuildCreate.js
index a920b02cf..96c5ae987 100644
--- a/src/client/websocket/packets/handlers/GuildCreate.js
+++ b/src/client/websocket/packets/handlers/GuildCreate.js
@@ -15,7 +15,7 @@ class GuildCreateHandler extends AbstractHandler {
}
} else {
// A new guild
- guild = client.guilds.create(data);
+ guild = client.guilds.add(data);
const emitEvent = client.ws.connection.status === Status.READY;
if (emitEvent) {
/**
diff --git a/src/client/websocket/packets/handlers/GuildMemberAdd.js b/src/client/websocket/packets/handlers/GuildMemberAdd.js
index de244ed63..15201b825 100644
--- a/src/client/websocket/packets/handlers/GuildMemberAdd.js
+++ b/src/client/websocket/packets/handlers/GuildMemberAdd.js
@@ -10,7 +10,7 @@ class GuildMemberAddHandler extends AbstractHandler {
const guild = client.guilds.get(data.guild_id);
if (guild) {
guild.memberCount++;
- const member = guild.members.create(data);
+ const member = guild.members.add(data);
if (client.ws.connection.status === Status.READY) {
client.emit(Events.GUILD_MEMBER_ADD, member);
}
diff --git a/src/client/websocket/packets/handlers/GuildMembersChunk.js b/src/client/websocket/packets/handlers/GuildMembersChunk.js
index 5985a9e42..4e821f5cc 100644
--- a/src/client/websocket/packets/handlers/GuildMembersChunk.js
+++ b/src/client/websocket/packets/handlers/GuildMembersChunk.js
@@ -10,7 +10,7 @@ class GuildMembersChunkHandler extends AbstractHandler {
if (!guild) return;
const members = new Collection();
- for (const member of data.members) members.set(member.user.id, guild.members.create(member));
+ for (const member of data.members) members.set(member.user.id, guild.members.add(member));
client.emit(Events.GUILD_MEMBERS_CHUNK, members, guild);
diff --git a/src/client/websocket/packets/handlers/PresenceUpdate.js b/src/client/websocket/packets/handlers/PresenceUpdate.js
index 4da269513..87732b8ce 100644
--- a/src/client/websocket/packets/handlers/PresenceUpdate.js
+++ b/src/client/websocket/packets/handlers/PresenceUpdate.js
@@ -11,7 +11,7 @@ class PresenceUpdateHandler extends AbstractHandler {
// Step 1
if (!user) {
if (data.user.username) {
- user = client.users.create(data.user);
+ user = client.users.add(data.user);
} else {
return;
}
@@ -25,7 +25,7 @@ class PresenceUpdateHandler extends AbstractHandler {
if (guild) {
let member = guild.members.get(user.id);
if (!member && data.status !== 'offline') {
- member = guild.members.create({
+ member = guild.members.add({
user,
roles: data.roles,
deaf: false,
@@ -35,17 +35,17 @@ class PresenceUpdateHandler extends AbstractHandler {
}
if (member) {
if (client.listenerCount(Events.PRESENCE_UPDATE) === 0) {
- guild.presences.create(data);
+ guild.presences.add(data);
return;
}
const oldMember = member._clone();
if (member.presence) {
oldMember.frozenPresence = member.presence._clone();
}
- guild.presences.create(data);
+ guild.presences.add(data);
client.emit(Events.PRESENCE_UPDATE, oldMember, member);
} else {
- guild.presences.create(data);
+ guild.presences.add(data);
}
}
}
diff --git a/src/client/websocket/packets/handlers/Ready.js b/src/client/websocket/packets/handlers/Ready.js
index b1a833d5f..367406ba0 100644
--- a/src/client/websocket/packets/handlers/Ready.js
+++ b/src/client/websocket/packets/handlers/Ready.js
@@ -18,11 +18,11 @@ class ReadyHandler extends AbstractHandler {
client.readyAt = new Date();
client.users.set(clientUser.id, clientUser);
- for (const guild of data.guilds) client.guilds.create(guild);
- for (const privateDM of data.private_channels) client.channels.create(privateDM);
+ for (const guild of data.guilds) client.guilds.add(guild);
+ for (const privateDM of data.private_channels) client.channels.add(privateDM);
for (const relation of data.relationships) {
- const user = client.users.create(relation.user);
+ const user = client.users.add(relation.user);
if (relation.type === 1) {
client.user.friends.set(user.id, user);
} else if (relation.type === 2) {
@@ -30,7 +30,7 @@ class ReadyHandler extends AbstractHandler {
}
}
- for (const presence of data.presences || []) client.presences.create(presence);
+ for (const presence of data.presences || []) client.presences.add(presence);
if (data.notes) {
for (const user in data.notes) {
@@ -42,7 +42,7 @@ class ReadyHandler extends AbstractHandler {
}
if (!client.users.has('1')) {
- client.users.create({
+ client.users.add({
id: '1',
username: 'Clyde',
discriminator: '0000',
diff --git a/src/errors/Messages.js b/src/errors/Messages.js
index 545452703..0485296a6 100644
--- a/src/errors/Messages.js
+++ b/src/errors/Messages.js
@@ -32,11 +32,8 @@ const Messages = {
COLOR_CONVERT: 'Unable to convert color to a number.',
EMBED_FIELD_COUNT: 'MessageEmbeds may not exceed 25 fields.',
- EMBED_FIELD_NAME: 'MessageEmbed field names may not exceed 256 characters or be empty.',
- EMBED_FIELD_VALUE: 'MessageEmbed field values may not exceed 1024 characters or be empty.',
- EMBED_DESCRIPTION: 'MessageEmbed descriptions may not exceed 2048 characters.',
- EMBED_FOOTER_TEXT: 'MessageEmbed footer text may not exceed 2048 characters.',
- EMBED_TITLE: 'MessageEmbed titles may not exceed 256 characters.',
+ EMBED_FIELD_NAME: 'MessageEmbed field names may not be empty.',
+ EMBED_FIELD_VALUE: 'MessageEmbed field values may not be empty.',
FILE_NOT_FOUND: file => `File could not be found: ${file}`,
@@ -54,6 +51,9 @@ const Messages = {
VOICE_NO_BROWSER: 'Voice connections are not available in browsers.',
VOICE_CONNECTION_ATTEMPTS_EXCEEDED: attempts => `Too many connection attempts (${attempts}).`,
VOICE_JOIN_SOCKET_CLOSED: 'Tried to send join packet, but the WebSocket is not open.',
+ VOICE_PLAY_INTERFACE_NO_BROADCAST: 'A broadcast cannot be played in this context.',
+ VOICE_PLAY_INTERFACE_BAD_TYPE: 'Unknown stream type',
+ VOICE_PRISM_DEMUXERS_NEED_STREAM: 'To play a webm/ogg stream, you need to pass a ReadableStream.',
OPUS_ENGINE_MISSING: 'Couldn\'t find an Opus engine.',
@@ -93,7 +93,7 @@ const Messages = {
WEBHOOK_MESSAGE: 'The message was not sent by a webhook.',
- EMOJI_TYPE: 'Emoji must be a string or Emoji/ReactionEmoji',
+ EMOJI_TYPE: 'Emoji must be a string or GuildEmoji/ReactionEmoji',
REACTION_RESOLVE_USER: 'Couldn\'t resolve the user ID to remove from the reaction.',
};
diff --git a/src/index.js b/src/index.js
index 244c6a5ea..b89d9ce56 100644
--- a/src/index.js
+++ b/src/index.js
@@ -26,9 +26,11 @@ module.exports = {
// Stores
ChannelStore: require('./stores/ChannelStore'),
ClientPresenceStore: require('./stores/ClientPresenceStore'),
- EmojiStore: require('./stores/EmojiStore'),
GuildChannelStore: require('./stores/GuildChannelStore'),
+ GuildEmojiStore: require('./stores/GuildEmojiStore'),
+ GuildEmojiRoleStore: require('./stores/GuildEmojiRoleStore'),
GuildMemberStore: require('./stores/GuildMemberStore'),
+ GuildMemberRoleStore: require('./stores/GuildMemberRoleStore'),
GuildStore: require('./stores/GuildStore'),
ReactionUserStore: require('./stores/ReactionUserStore'),
MessageStore: require('./stores/MessageStore'),
@@ -37,8 +39,11 @@ module.exports = {
UserStore: require('./stores/UserStore'),
// Shortcuts to Util methods
+ discordSort: Util.discordSort,
escapeMarkdown: Util.escapeMarkdown,
fetchRecommendedShards: Util.fetchRecommendedShards,
+ resolveColor: Util.resolveColor,
+ resolveString: Util.resolveString,
splitMessage: Util.splitMessage,
// Structures
@@ -61,6 +66,7 @@ module.exports = {
Guild: require('./structures/Guild'),
GuildAuditLogs: require('./structures/GuildAuditLogs'),
GuildChannel: require('./structures/GuildChannel'),
+ GuildEmoji: require('./structures/GuildEmoji'),
GuildMember: require('./structures/GuildMember'),
Invite: require('./structures/Invite'),
Message: require('./structures/Message'),
diff --git a/src/rest/APIRequest.js b/src/rest/APIRequest.js
index ae4a352f3..e0527320d 100644
--- a/src/rest/APIRequest.js
+++ b/src/rest/APIRequest.js
@@ -10,20 +10,17 @@ class APIRequest {
this.rest = rest;
this.client = rest.client;
this.method = method;
- this.path = path.toString();
this.route = options.route;
this.options = options;
+
+ const queryString = (querystring.stringify(options.query).match(/[^=&?]+=[^=&?]+/g) || []).join('&');
+ this.path = `${path}${queryString ? `?${queryString}` : ''}`;
}
gen() {
const API = this.options.versioned === false ? this.client.options.http.api :
`${this.client.options.http.api}/v${this.client.options.http.version}`;
- if (this.options.query) {
- const queryString = (querystring.stringify(this.options.query).match(/[^=&?]+=[^=&?]+/g) || []).join('&');
- this.path += `?${queryString}`;
- }
-
const request = snekfetch[this.method](`${API}${this.path}`, { agent });
if (this.options.auth !== false) request.set('Authorization', this.rest.getAuth());
diff --git a/src/sharding/Shard.js b/src/sharding/Shard.js
index 0e44f91a2..96ab796dc 100644
--- a/src/sharding/Shard.js
+++ b/src/sharding/Shard.js
@@ -42,6 +42,7 @@ class Shard extends EventEmitter {
* @type {Object}
*/
this.env = Object.assign({}, process.env, {
+ SHARDING_MANAGER: true,
SHARD_ID: this.id,
SHARD_COUNT: this.manager.totalShards,
CLIENT_TOKEN: this.manager.token,
@@ -174,8 +175,8 @@ class Shard extends EventEmitter {
}
/**
- * Evaluates a script on the shard, in the context of the {@link Client}.
- * @param {string} script JavaScript to run on the shard
+ * Evaluates a script or function on the shard, in the context of the {@link Client}.
+ * @param {string|Function} script JavaScript to run on the shard
* @returns {Promise<*>} Result of the script execution
*/
eval(script) {
@@ -190,7 +191,8 @@ class Shard extends EventEmitter {
};
this.process.on('message', listener);
- this.send({ _eval: script }).catch(err => {
+ const _eval = typeof script === 'function' ? `(${script})(this)` : script;
+ this.send({ _eval }).catch(err => {
this.process.removeListener('message', listener);
this._evals.delete(script);
reject(err);
diff --git a/src/sharding/ShardClientUtil.js b/src/sharding/ShardClientUtil.js
index b0e9d57ed..f4066717a 100644
--- a/src/sharding/ShardClientUtil.js
+++ b/src/sharding/ShardClientUtil.js
@@ -86,6 +86,7 @@ class ShardClientUtil {
*/
broadcastEval(script) {
return new Promise((resolve, reject) => {
+ script = typeof script === 'function' ? `(${script})(this)` : script;
const listener = message => {
if (!message || message._sEval !== script) return;
process.removeListener('message', listener);
@@ -118,7 +119,7 @@ class ShardClientUtil {
* @param {*} message Message received
* @private
*/
- _handleMessage(message) {
+ async _handleMessage(message) {
if (!message) return;
if (message._fetchProp) {
const props = message._fetchProp.split('.');
@@ -127,7 +128,7 @@ class ShardClientUtil {
this._respond('fetchProp', { _fetchProp: message._fetchProp, _result: value });
} else if (message._eval) {
try {
- this._respond('eval', { _eval: message._eval, _result: this.client._eval(message._eval) });
+ this._respond('eval', { _eval: message._eval, _result: await this.client._eval(message._eval) });
} catch (err) {
this._respond('eval', { _eval: message._eval, _error: Util.makePlainError(err) });
}
diff --git a/src/stores/ChannelStore.js b/src/stores/ChannelStore.js
index 2ceee8000..6e2e4081a 100644
--- a/src/stores/ChannelStore.js
+++ b/src/stores/ChannelStore.js
@@ -7,7 +7,6 @@ const lruable = ['group', 'dm'];
/**
* Stores channels.
- * @private
* @extends {DataStore}
*/
class ChannelStore extends DataStore {
@@ -51,7 +50,7 @@ class ChannelStore extends DataStore {
return super.delete(key);
}
- create(data, guild, cache = true) {
+ add(data, guild, cache = true) {
const existing = this.get(data.id);
if (existing) return existing;
diff --git a/src/stores/ClientPresenceStore.js b/src/stores/ClientPresenceStore.js
index 12213059c..94d48d4a4 100644
--- a/src/stores/ClientPresenceStore.js
+++ b/src/stores/ClientPresenceStore.js
@@ -7,7 +7,6 @@ const { TypeError } = require('../errors');
/**
* Stores the client presence and other presences.
* @extends {PresenceStore}
- * @private
*/
class ClientPresenceStore extends PresenceStore {
constructor(...args) {
@@ -20,7 +19,14 @@ class ClientPresenceStore extends PresenceStore {
});
}
- async setClientPresence({ status, since, afk, activity }) { // eslint-disable-line complexity
+ async setClientPresence(presence) {
+ const packet = await this._parse(presence);
+ this.clientPresence.patch(packet);
+ this.client.ws.send({ op: OPCodes.STATUS_UPDATE, d: packet });
+ return this.clientPresence;
+ }
+
+ async _parse({ status, since, afk, activity }) { // eslint-disable-line complexity
const applicationID = activity && (activity.application ? activity.application.id || activity.application : null);
let assets = new Collection();
if (activity) {
@@ -39,7 +45,7 @@ class ClientPresenceStore extends PresenceStore {
since: since != null ? since : null, // eslint-disable-line eqeqeq
status: status || this.clientPresence.status,
game: activity ? {
- type: typeof activity.type === 'number' ? activity.type : ActivityTypes.indexOf(activity.type),
+ type: activity.type,
name: activity.name,
url: activity.url,
details: activity.details || undefined,
@@ -58,9 +64,16 @@ class ClientPresenceStore extends PresenceStore {
} : null,
};
- this.clientPresence.patch(packet);
- this.client.ws.send({ op: OPCodes.STATUS_UPDATE, d: packet });
- return this.clientPresence;
+ if ((status || afk || since) && !activity) {
+ packet.game = this.clientPresence.activity;
+ }
+
+ if (packet.game) {
+ packet.game.type = typeof packet.game.type === 'number' ?
+ packet.game.type : ActivityTypes.indexOf(packet.game.type);
+ }
+
+ return packet;
}
}
diff --git a/src/stores/DataStore.js b/src/stores/DataStore.js
index c4256dfe2..72f86028e 100644
--- a/src/stores/DataStore.js
+++ b/src/stores/DataStore.js
@@ -11,10 +11,10 @@ class DataStore extends Collection {
if (!Structures) Structures = require('../util/Structures');
Object.defineProperty(this, 'client', { value: client });
Object.defineProperty(this, 'holds', { value: Structures.get(holds.name) || holds });
- if (iterable) for (const item of iterable) this.create(item);
+ if (iterable) for (const item of iterable) this.add(item);
}
- create(data, cache = true, { id, extras = [] } = {}) {
+ add(data, cache = true, { id, extras = [] } = {}) {
const existing = this.get(id || data.id);
if (existing) return existing;
diff --git a/src/stores/EmojiStore.js b/src/stores/EmojiStore.js
deleted file mode 100644
index 035cc3d4d..000000000
--- a/src/stores/EmojiStore.js
+++ /dev/null
@@ -1,71 +0,0 @@
-const DataStore = require('./DataStore');
-const Emoji = require('../structures/Emoji');
-const ReactionEmoji = require('../structures/ReactionEmoji');
-
-/**
- * Stores emojis.
- * @private
- * @extends {DataStore}
- */
-class EmojiStore extends DataStore {
- constructor(guild, iterable) {
- super(guild.client, iterable, Emoji);
- this.guild = guild;
- }
-
- create(data, cache) {
- return super.create(data, cache, { extras: [this.guild] });
- }
-
- /**
- * Data that can be resolved into an Emoji object. This can be:
- * * A custom emoji ID
- * * An Emoji object
- * * A ReactionEmoji object
- * @typedef {Snowflake|Emoji|ReactionEmoji} EmojiResolvable
- */
-
- /**
- * Resolves a EmojiResolvable to a Emoji object.
- * @param {EmojiResolvable} emoji The Emoji resolvable to identify
- * @returns {?Emoji}
- */
- resolve(emoji) {
- if (emoji instanceof ReactionEmoji) return super.resolve(emoji.id);
- return super.resolve(emoji);
- }
-
- /**
- * Resolves a EmojiResolvable to a Emoji ID string.
- * @param {EmojiResolvable} emoji The Emoji resolvable to identify
- * @returns {?Snowflake}
- */
- resolveID(emoji) {
- if (emoji instanceof ReactionEmoji) return emoji.id;
- return super.resolveID(emoji);
- }
-
- /**
- * Data that can be resolved to give an emoji identifier. This can be:
- * * The unicode representation of an emoji
- * * An EmojiResolveable
- * @typedef {string|EmojiResolvable} EmojiIdentifierResolvable
- */
-
- /**
- * Resolves an EmojiResolvable to an emoji identifier.
- * @param {EmojiIdentifierResolvable} emoji The emoji resolvable to resolve
- * @returns {?string}
- */
- resolveIdentifier(emoji) {
- const emojiResolveable = this.resolve(emoji);
- if (emojiResolveable) return emojiResolveable.identifier;
- if (typeof emoji === 'string') {
- if (!emoji.includes('%')) return encodeURIComponent(emoji);
- else return emoji;
- }
- return null;
- }
-}
-
-module.exports = EmojiStore;
diff --git a/src/stores/GuildChannelStore.js b/src/stores/GuildChannelStore.js
index 0bc3e8c4e..c4d0c6fea 100644
--- a/src/stores/GuildChannelStore.js
+++ b/src/stores/GuildChannelStore.js
@@ -1,10 +1,12 @@
-const DataStore = require('./DataStore');
+const Collection = require('../util/Collection');
const Channel = require('../structures/Channel');
+const { ChannelTypes } = require('../util/Constants');
+const DataStore = require('./DataStore');
const GuildChannel = require('../structures/GuildChannel');
+const Permissions = require('../util/Permissions');
/**
* Stores guild channels.
- * @private
* @extends {DataStore}
*/
class GuildChannelStore extends DataStore {
@@ -13,13 +15,80 @@ class GuildChannelStore extends DataStore {
this.guild = guild;
}
- create(data) {
+ add(data) {
const existing = this.get(data.id);
if (existing) return existing;
return Channel.create(this.client, data, this.guild);
}
+ /**
+ * Can be used to overwrite permissions when creating a channel.
+ * @typedef {Object} ChannelCreationOverwrites
+ * @property {PermissionResolvable[]|number} [allow] The permissions to allow
+ * @property {PermissionResolvable[]|number} [deny] The permissions to deny
+ * @property {RoleResolvable|UserResolvable} id ID of the role or member this overwrite is for
+ */
+
+ /**
+ * Creates a new channel in the guild.
+ * @param {string} name The name of the new channel
+ * @param {Object} [options] Options
+ * @param {string} [options.type='text'] The type of the new channel, either `text`, `voice`, or `category`
+ * @param {boolean} [options.nsfw] Whether the new channel is nsfw
+ * @param {number} [options.bitrate] Bitrate of the new channel in bits (only voice)
+ * @param {number} [options.userLimit] Maximum amount of users allowed in the new channel (only voice)
+ * @param {ChannelResolvable} [options.parent] Parent of the new channel
+ * @param {Array} [options.overwrites] Permission overwrites
+ * @param {string} [options.reason] Reason for creating the channel
+ * @returns {Promise}
+ * @example
+ * // Create a new text channel
+ * guild.channels.create('new-general', { reason: 'Needed a cool new channel' })
+ * .then(console.log)
+ * .catch(console.error);
+ */
+ create(name, { type, nsfw, bitrate, userLimit, parent, overwrites, reason } = {}) {
+ if (overwrites instanceof Collection || overwrites instanceof Array) {
+ overwrites = overwrites.map(overwrite => {
+ let allow = overwrite.allow || (overwrite.allowed ? overwrite.allowed.bitfield : 0);
+ let deny = overwrite.deny || (overwrite.denied ? overwrite.denied.bitfield : 0);
+ if (allow instanceof Array) allow = Permissions.resolve(allow);
+ if (deny instanceof Array) deny = Permissions.resolve(deny);
+
+ const role = this.guild.roles.resolve(overwrite.id);
+ if (role) {
+ overwrite.id = role.id;
+ overwrite.type = 'role';
+ } else {
+ overwrite.id = this.client.users.resolveID(overwrite.id);
+ overwrite.type = 'member';
+ }
+
+ return {
+ allow,
+ deny,
+ type: overwrite.type,
+ id: overwrite.id,
+ };
+ });
+ }
+
+ if (parent) parent = this.client.channels.resolveID(parent);
+ return this.client.api.guilds(this.guild.id).channels.post({
+ data: {
+ name,
+ type: type ? ChannelTypes[type.toUpperCase()] : 'text',
+ nsfw,
+ bitrate,
+ user_limit: userLimit,
+ parent_id: parent,
+ permission_overwrites: overwrites,
+ },
+ reason,
+ }).then(data => this.client.actions.ChannelCreate.handle(data).channel);
+ }
+
/**
* Data that can be resolved to give a Guild Channel object. This can be:
* * A GuildChannel object
diff --git a/src/stores/GuildEmojiRoleStore.js b/src/stores/GuildEmojiRoleStore.js
new file mode 100644
index 000000000..2471fb5fe
--- /dev/null
+++ b/src/stores/GuildEmojiRoleStore.js
@@ -0,0 +1,110 @@
+const DataStore = require('./DataStore');
+const Collection = require('../util/Collection');
+const { TypeError } = require('../errors');
+
+/**
+ * Stores emoji roles
+ * @extends {DataStore}
+ */
+class GuildEmojiRoleStore extends DataStore {
+ constructor(emoji) {
+ super(emoji.client, null, require('../structures/GuildEmoji'));
+ this.emoji = emoji;
+ this.guild = emoji.guild;
+ }
+
+ /**
+ * Adds a role (or multiple roles) to the list of roles that can use this emoji.
+ * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to add
+ * @returns {Promise}
+ */
+ add(roleOrRoles) {
+ if (roleOrRoles instanceof Collection) return this.add(roleOrRoles.keyArray());
+ if (!(roleOrRoles instanceof Array)) return this.add([roleOrRoles]);
+
+ roleOrRoles = roleOrRoles.map(r => this.guild.roles.resolve(r));
+
+ if (roleOrRoles.includes(null)) {
+ return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
+ 'Array or Collection of Roles or Snowflakes', true));
+ } else {
+ for (const role of roleOrRoles) super.set(role.id, role);
+ }
+
+ return this.set(this);
+ }
+
+ /**
+ * Removes a role (or multiple roles) from the list of roles that can use this emoji.
+ * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to remove
+ * @returns {Promise}
+ */
+ remove(roleOrRoles) {
+ if (roleOrRoles instanceof Collection) return this.remove(roleOrRoles.keyArray());
+ if (!(roleOrRoles instanceof Array)) return this.remove([roleOrRoles]);
+
+ roleOrRoles = roleOrRoles.map(r => this.guild.roles.resolveID(r));
+
+ if (roleOrRoles.includes(null)) {
+ return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
+ 'Array or Collection of Roles or Snowflakes', true));
+ } else {
+ for (const role of roleOrRoles) super.remove(role);
+ }
+
+ return this.set(this);
+ }
+
+ /**
+ * Sets the role(s) that can use this emoji.
+ * @param {Collection|RoleResolvable[]} roles The roles or role IDs to apply
+ * @returns {Promise}
+ * @example
+ * // Set the emoji's roles to a single role
+ * guildEmoji.roles.set(['391156570408615936'])
+ * .then(console.log)
+ * .catch(console.error);
+ * @example
+ * // Remove all roles from an emoji
+ * guildEmoji.roles.set([])
+ * .then(console.log)
+ * .catch(console.error);
+ */
+ set(roles) {
+ return this.emoji.edit({ roles });
+ }
+
+ /**
+ * Patches the roles for this store
+ * @param {Snowflake[]} roles The new roles
+ * @private
+ */
+ _patch(roles) {
+ this.clear();
+
+ for (let role of roles) {
+ role = this.guild.roles.resolve(role);
+ if (role) super.set(role.id, role);
+ }
+ }
+
+ /**
+ * Resolves a RoleResolvable to a Role object.
+ * @method resolve
+ * @memberof GuildEmojiRoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Role}
+ */
+
+ /**
+ * Resolves a RoleResolvable to a role ID string.
+ * @method resolveID
+ * @memberof GuildEmojiRoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Snowflake}
+ */
+}
+
+module.exports = GuildEmojiRoleStore;
diff --git a/src/stores/GuildEmojiStore.js b/src/stores/GuildEmojiStore.js
new file mode 100644
index 000000000..75bf16e08
--- /dev/null
+++ b/src/stores/GuildEmojiStore.js
@@ -0,0 +1,114 @@
+const Collection = require('../util/Collection');
+const DataStore = require('./DataStore');
+const GuildEmoji = require('../structures/GuildEmoji');
+const ReactionEmoji = require('../structures/ReactionEmoji');
+const DataResolver = require('../util/DataResolver');
+const { TypeError } = require('../errors');
+
+/**
+ * Stores guild emojis.
+ * @extends {DataStore}
+ */
+class GuildEmojiStore extends DataStore {
+ constructor(guild, iterable) {
+ super(guild.client, iterable, GuildEmoji);
+ this.guild = guild;
+ }
+
+ add(data, cache) {
+ return super.add(data, cache, { extras: [this.guild] });
+ }
+
+ /**
+ * Creates a new custom emoji in the guild.
+ * @param {BufferResolvable|Base64Resolvable} attachment The image for the emoji
+ * @param {string} name The name for the emoji
+ * @param {Object} [options] Options
+ * @param {Collection|RoleResolvable[]} [options.roles] Roles to limit the emoji to
+ * @param {string} [options.reason] Reason for creating the emoji
+ * @returns {Promise} The created emoji
+ * @example
+ * // Create a new emoji from a url
+ * guild.emojis.create('https://i.imgur.com/w3duR07.png', 'rip')
+ * .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.emojis.create('./memes/banana.png', 'banana')
+ * .then(emoji => console.log(`Created new emoji with name ${emoji.name}!`))
+ * .catch(console.error);
+ */
+ create(attachment, name, { roles, reason } = {}) {
+ if (typeof attachment === 'string' && attachment.startsWith('data:')) {
+ const data = { image: attachment, name };
+ if (roles) {
+ data.roles = [];
+ for (let role of roles instanceof Collection ? roles.values() : roles) {
+ role = this.guild.roles.resolve(role);
+ if (!role) {
+ return Promise.reject(new TypeError('INVALID_TYPE', 'options.roles',
+ 'Array or Collection of Roles or Snowflakes', true));
+ }
+ data.roles.push(role.id);
+ }
+ }
+
+ return this.client.api.guilds(this.guild.id).emojis.post({ data, reason })
+ .then(emoji => this.client.actions.GuildEmojiCreate.handle(this.guild, emoji).emoji);
+ }
+
+ return DataResolver.resolveImage(attachment).then(image => this.create(image, name, { roles, reason }));
+ }
+
+ /**
+ * Data that can be resolved into an GuildEmoji object. This can be:
+ * * A custom emoji ID
+ * * A GuildEmoji object
+ * * A ReactionEmoji object
+ * @typedef {Snowflake|GuildEmoji|ReactionEmoji} EmojiResolvable
+ */
+
+ /**
+ * Resolves an EmojiResolvable to an Emoji object.
+ * @param {EmojiResolvable} emoji The Emoji resolvable to identify
+ * @returns {?GuildEmoji}
+ */
+ resolve(emoji) {
+ if (emoji instanceof ReactionEmoji) return super.resolve(emoji.id);
+ return super.resolve(emoji);
+ }
+
+ /**
+ * Resolves an EmojiResolvable to an Emoji ID string.
+ * @param {EmojiResolvable} emoji The Emoji resolvable to identify
+ * @returns {?Snowflake}
+ */
+ resolveID(emoji) {
+ if (emoji instanceof ReactionEmoji) return emoji.id;
+ return super.resolveID(emoji);
+ }
+
+ /**
+ * Data that can be resolved to give an emoji identifier. This can be:
+ * * The unicode representation of an emoji
+ * * An EmojiResolveable
+ * @typedef {string|EmojiResolvable} EmojiIdentifierResolvable
+ */
+
+ /**
+ * Resolves an EmojiResolvable to an emoji identifier.
+ * @param {EmojiIdentifierResolvable} emoji The emoji resolvable to resolve
+ * @returns {?string}
+ */
+ resolveIdentifier(emoji) {
+ const emojiResolveable = this.resolve(emoji);
+ if (emojiResolveable) return emojiResolveable.identifier;
+ if (typeof emoji === 'string') {
+ if (!emoji.includes('%')) return encodeURIComponent(emoji);
+ else return emoji;
+ }
+ return null;
+ }
+}
+
+module.exports = GuildEmojiStore;
diff --git a/src/stores/GuildMemberRoleStore.js b/src/stores/GuildMemberRoleStore.js
new file mode 100644
index 000000000..af9d08ef1
--- /dev/null
+++ b/src/stores/GuildMemberRoleStore.js
@@ -0,0 +1,156 @@
+const DataStore = require('./DataStore');
+const Role = require('../structures/Role');
+const Collection = require('../util/Collection');
+const { TypeError } = require('../errors');
+
+/**
+ * Stores member roles
+ * @extends {DataStore}
+ */
+class GuildMemberRoleStore extends DataStore {
+ constructor(member) {
+ super(member.client, null, Role);
+ this.member = member;
+ this.guild = member.guild;
+ }
+
+ /**
+ * Adds a role (or multiple roles) to the member.
+ * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to add
+ * @param {string} [reason] Reason for adding the role(s)
+ * @returns {Promise}
+ */
+ add(roleOrRoles, reason) {
+ if (roleOrRoles instanceof Collection) return this.add(roleOrRoles.keyArray(), reason);
+ if (!(roleOrRoles instanceof Array)) return this.add([roleOrRoles], reason);
+
+ roleOrRoles = roleOrRoles.map(r => this.guild.roles.resolve(r));
+
+ if (roleOrRoles.includes(null)) {
+ return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
+ 'Array or Collection of Roles or Snowflakes', true));
+ } else {
+ for (const role of roleOrRoles) super.set(role.id, role);
+ }
+
+ return this.set(this, reason);
+ }
+
+ /**
+ * Sets the roles applied to the member.
+ * @param {Collection|RoleResolvable[]} roles The roles or role IDs to apply
+ * @param {string} [reason] Reason for applying the roles
+ * @returns {Promise}
+ * @example
+ * // Set the member's roles to a single role
+ * guildMember.roles.set(['391156570408615936'])
+ * .then(console.log)
+ * .catch(console.error);
+ * @example
+ * // Remove all the roles from a member
+ * guildMember.roles.set([])
+ * .then(member => console.log(`Member roles is now of ${member.roles.size} size`))
+ * .catch(console.error);
+ */
+ set(roles, reason) {
+ return this.member.edit({ roles }, reason);
+ }
+
+ /**
+ * Removes a role (or multiple roles) from the member.
+ * @param {RoleResolvable|RoleResolvable[]|Collection} roleOrRoles The role or roles to remove
+ * @param {string} [reason] Reason for removing the role(s)
+ * @returns {Promise}
+ */
+ remove(roleOrRoles, reason) {
+ if (roleOrRoles instanceof Collection) return this.remove(roleOrRoles.keyArray(), reason);
+ if (!(roleOrRoles instanceof Array)) return this.remove([roleOrRoles], reason);
+
+ roleOrRoles = roleOrRoles.map(r => this.guild.roles.resolveID(r));
+
+ if (roleOrRoles.includes(null)) {
+ return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
+ 'Array or Collection of Roles or Snowflakes', true));
+ } else {
+ for (const role of roleOrRoles) super.remove(role);
+ }
+
+ return this.set(this, reason);
+ }
+
+ /**
+ * The role of the member used to hoist them in a separate category in the users list
+ * @type {?Role}
+ * @readonly
+ */
+ get hoist() {
+ const hoistedRoles = this.filter(role => role.hoist);
+ if (!hoistedRoles.size) return null;
+ return hoistedRoles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
+ }
+
+ /**
+ * The role of the member used to set their color
+ * @type {?Role}
+ * @readonly
+ */
+ get color() {
+ const coloredRoles = this.filter(role => role.color);
+ if (!coloredRoles.size) return null;
+ return coloredRoles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
+ }
+
+ /**
+ * The role of the member with the highest position
+ * @type {Role}
+ * @readonly
+ */
+ get highest() {
+ return this.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
+ }
+
+ /**
+ * Patches the roles for this store
+ * @param {Snowflake[]} roles The new roles
+ * @private
+ */
+ _patch(roles) {
+ this.clear();
+
+ const everyoneRole = this.guild.roles.get(this.guild.id);
+ if (everyoneRole) super.set(everyoneRole.id, everyoneRole);
+
+ if (roles) {
+ for (const roleID of roles) {
+ const role = this.guild.roles.resolve(roleID);
+ if (role) super.set(role.id, role);
+ }
+ }
+ }
+
+ clone() {
+ const clone = new this.constructor(this.member);
+ clone._patch(this.keyArray());
+ return clone;
+ }
+
+ /**
+ * Resolves a RoleResolvable to a Role object.
+ * @method resolve
+ * @memberof GuildMemberRoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Role}
+ */
+
+ /**
+ * Resolves a RoleResolvable to a role ID string.
+ * @method resolveID
+ * @memberof GuildMemberRoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Snowflake}
+ */
+}
+
+module.exports = GuildMemberRoleStore;
diff --git a/src/stores/GuildMemberStore.js b/src/stores/GuildMemberStore.js
index ad0acbed9..6c4b5947f 100644
--- a/src/stores/GuildMemberStore.js
+++ b/src/stores/GuildMemberStore.js
@@ -2,7 +2,7 @@ const DataStore = require('./DataStore');
const GuildMember = require('../structures/GuildMember');
const { Events, OPCodes } = require('../util/Constants');
const Collection = require('../util/Collection');
-const { Error } = require('../errors');
+const { Error, TypeError } = require('../errors');
/**
* Stores guild members.
@@ -14,8 +14,8 @@ class GuildMemberStore extends DataStore {
this.guild = guild;
}
- create(data, cache) {
- return super.create(data, cache, { extras: [this.guild] });
+ add(data, cache) {
+ return super.add(data, cache, { extras: [this.guild] });
}
/**
@@ -72,21 +72,22 @@ class GuildMemberStore extends DataStore {
* @returns {Promise|Promise>}
* @example
* // Fetch all members from a guild
- * guild.members.fetch();
+ * guild.members.fetch()
+ * .then(console.log)
+ * .catch(console.error);
* @example
* // Fetch a single member
- * guild.members.fetch('66564597481480192');
- * guild.members.fetch(user);
- * guild.members.fetch({ user, cache: false }); // Fetch and don't cache
+ * guild.members.fetch('66564597481480192')
+ * .then(console.log)
+ * .catch(console.error);
+ * guild.members.fetch({ user, cache: false }) // Fetch and don't cache
+ * .then(console.log)
+ * .catch(console.error);
* @example
* // Fetch by query
- * guild.members.fetch({
- * query: 'hydra',
- * });
- * guild.members.fetch({
- * query: 'hydra',
- * limit: 10,
- * });
+ * guild.members.fetch({ query: 'hydra' })
+ * .then(console.log)
+ * .catch(console.error);
*/
fetch(options) {
if (!options) return this._fetchMany();
@@ -99,11 +100,85 @@ class GuildMemberStore extends DataStore {
return this._fetchMany(options);
}
+ /**
+ * Prunes members from the guild based on how long they have been inactive.
+ * @param {Object} [options] Prune options
+ * @param {number} [options.days=7] Number of days of inactivity required to kick
+ * @param {boolean} [options.dry=false] Get number of users that will be kicked, without actually kicking them
+ * @param {string} [options.reason] Reason for this prune
+ * @returns {Promise} The number of members that were/will be kicked
+ * @example
+ * // See how many members will be pruned
+ * guild.members.prune({ dry: true })
+ * .then(pruned => console.log(`This will prune ${pruned} people!`))
+ * .catch(console.error);
+ * @example
+ * // Actually prune the members
+ * guild.members.prune({ days: 1, reason: 'too many people!' })
+ * .then(pruned => console.log(`I just pruned ${pruned} people!`))
+ * .catch(console.error);
+ */
+ prune({ days = 7, dry = false, reason } = {}) {
+ if (typeof days !== 'number') throw new TypeError('PRUNE_DAYS_TYPE');
+ return this.client.api.guilds(this.guild.id).prune[dry ? 'get' : 'post']({ query: { days }, reason })
+ .then(data => data.pruned);
+ }
+
+ /**
+ * Bans a user from the guild.
+ * @param {UserResolvable} user The user to ban
+ * @param {Object} [options] Options for the ban
+ * @param {number} [options.days=0] Number of days of messages to delete
+ * @param {string} [options.reason] Reason for banning
+ * @returns {Promise} Result object will be resolved as specifically as possible.
+ * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot
+ * be resolved, the user ID will be the result.
+ * @example
+ * // Ban a user by ID (or with a user/guild member object)
+ * guild.members.ban('84484653687267328')
+ * .then(user => console.log(`Banned ${user.username || user.id || user} from ${guild.name}`))
+ * .catch(console.error);
+ */
+ ban(user, options = { days: 0 }) {
+ if (options.days) options['delete-message-days'] = options.days;
+ const id = this.client.users.resolveID(user);
+ if (!id) return Promise.reject(new Error('BAN_RESOLVE_ID', true));
+ return this.client.api.guilds(this.guild.id).bans[id].put({ query: options })
+ .then(() => {
+ if (user instanceof GuildMember) return user;
+ const _user = this.client.users.resolve(id);
+ if (_user) {
+ const member = this.resolve(_user);
+ return member || _user;
+ }
+ return id;
+ });
+ }
+
+ /**
+ * Unbans a user from the guild.
+ * @param {UserResolvable} user The user to unban
+ * @param {string} [reason] Reason for unbanning user
+ * @returns {Promise}
+ * @example
+ * // Unban a user by ID (or with a user/guild member object)
+ * guild.members.unban('84484653687267328')
+ * .then(user => console.log(`Unbanned ${user.username} from ${guild.name}`))
+ * .catch(console.error);
+ */
+ unban(user, reason) {
+ const id = this.client.users.resolveID(user);
+ if (!id) throw new Error('BAN_RESOLVE_ID');
+ return this.client.api.guilds(this.guild.id).bans[id].delete({ reason })
+ .then(() => user);
+ }
+
+
_fetchSingle({ user, cache }) {
const existing = this.get(user);
if (existing) return Promise.resolve(existing);
return this.client.api.guilds(this.guild.id).members(user).get()
- .then(data => this.create(data, cache));
+ .then(data => this.add(data, cache));
}
_fetchMany({ query = '', limit = 0 } = {}) {
diff --git a/src/stores/GuildStore.js b/src/stores/GuildStore.js
index 45e9c9cfc..35701087f 100644
--- a/src/stores/GuildStore.js
+++ b/src/stores/GuildStore.js
@@ -1,9 +1,10 @@
const DataStore = require('./DataStore');
+const DataResolver = require('../util/DataResolver');
+const { Events } = require('../util/Constants');
const Guild = require('../structures/Guild');
/**
* Stores guilds.
- * @private
* @extends {DataStore}
*/
class GuildStore extends DataStore {
@@ -35,6 +36,44 @@ class GuildStore extends DataStore {
* @param {GuildResolvable} guild The guild resolvable to identify
* @returns {?Snowflake}
*/
+
+ /**
+ * Creates a guild.
+ * This is only available when using a user account.
+ * @param {string} name The name of the guild
+ * @param {Object} [options] Options for the creating
+ * @param {string} [options.region] The region for the server, defaults to the closest one available
+ * @param {BufferResolvable|Base64Resolvable} [options.icon=null] The icon for the guild
+ * @returns {Promise} The guild that was created
+ */
+ create(name, { region, icon = null } = {}) {
+ if (!icon || (typeof icon === 'string' && icon.startsWith('data:'))) {
+ return new Promise((resolve, reject) =>
+ this.client.api.guilds.post({ data: { name, region, icon } })
+ .then(data => {
+ if (this.client.guilds.has(data.id)) return resolve(this.client.guilds.get(data.id));
+
+ const handleGuild = guild => {
+ if (guild.id === data.id) {
+ this.client.removeListener(Events.GUILD_CREATE, handleGuild);
+ this.client.clearTimeout(timeout);
+ resolve(guild);
+ }
+ };
+ this.client.on(Events.GUILD_CREATE, handleGuild);
+
+ const timeout = this.client.setTimeout(() => {
+ this.client.removeListener(Events.GUILD_CREATE, handleGuild);
+ resolve(this.client.guilds.add(data));
+ }, 10000);
+ return undefined;
+ }, reject)
+ );
+ }
+
+ return DataResolver.resolveImage(icon)
+ .then(data => this.create(name, { region, icon: data || null }));
+ }
}
module.exports = GuildStore;
diff --git a/src/stores/MessageStore.js b/src/stores/MessageStore.js
index cc0ff60b0..565f4c13d 100644
--- a/src/stores/MessageStore.js
+++ b/src/stores/MessageStore.js
@@ -13,8 +13,8 @@ class MessageStore extends DataStore {
this.channel = channel;
}
- create(data, cache) {
- return super.create(data, cache, { extras: [this.channel] });
+ add(data, cache) {
+ return super.add(data, cache, { extras: [this.channel] });
}
set(key, value) {
@@ -62,7 +62,7 @@ class MessageStore extends DataStore {
fetchPinned() {
return this.client.api.channels[this.channel.id].pins.get().then(data => {
const messages = new Collection();
- for (const message of data) messages.set(message.id, this.create(message));
+ for (const message of data) messages.set(message.id, this.add(message));
return messages;
});
}
@@ -77,14 +77,14 @@ class MessageStore extends DataStore {
});
}
return this.client.api.channels[this.channel.id].messages[messageID].get()
- .then(data => this.create(data));
+ .then(data => this.add(data));
}
_fetchMany(options = {}) {
return this.client.api.channels[this.channel.id].messages.get({ query: options })
.then(data => {
const messages = new Collection();
- for (const message of data) messages.set(message.id, this.create(message));
+ for (const message of data) messages.set(message.id, this.add(message));
return messages;
});
}
diff --git a/src/stores/PresenceStore.js b/src/stores/PresenceStore.js
index 8322c9c65..79f0c525c 100644
--- a/src/stores/PresenceStore.js
+++ b/src/stores/PresenceStore.js
@@ -3,7 +3,6 @@ const { Presence } = require('../structures/Presence');
/**
* Stores presences.
- * @private
* @extends {DataStore}
*/
class PresenceStore extends DataStore {
@@ -11,9 +10,9 @@ class PresenceStore extends DataStore {
super(client, iterable, Presence);
}
- create(data, cache) {
+ add(data, cache) {
const existing = this.get(data.user.id);
- return existing ? existing.patch(data) : super.create(data, cache, { id: data.user.id });
+ return existing ? existing.patch(data) : super.add(data, cache, { id: data.user.id });
}
/**
diff --git a/src/stores/ReactionStore.js b/src/stores/ReactionStore.js
index c11b4e176..38c467b79 100644
--- a/src/stores/ReactionStore.js
+++ b/src/stores/ReactionStore.js
@@ -3,7 +3,6 @@ const MessageReaction = require('../structures/MessageReaction');
/**
* Stores reactions.
- * @private
* @extends {DataStore}
*/
class ReactionStore extends DataStore {
@@ -12,8 +11,8 @@ class ReactionStore extends DataStore {
this.message = message;
}
- create(data, cache) {
- return super.create(data, cache, { id: data.emoji.id || data.emoji.name, extras: [this.message] });
+ add(data, cache) {
+ return super.add(data, cache, { id: data.emoji.id || data.emoji.name, extras: [this.message] });
}
/**
@@ -40,6 +39,15 @@ class ReactionStore extends DataStore {
* @param {MessageReactionResolvable} role The role resolvable to resolve
* @returns {?Snowflake}
*/
+
+ /**
+ * Removes all reactions from a message.
+ * @returns {Promise}
+ */
+ removeAll() {
+ return this.client.api.channels(this.message.channel.id).messages(this.message.id).reactions.delete()
+ .then(() => this.message);
+ }
}
module.exports = ReactionStore;
diff --git a/src/stores/ReactionUserStore.js b/src/stores/ReactionUserStore.js
index b3c3ec012..a07a9a093 100644
--- a/src/stores/ReactionUserStore.js
+++ b/src/stores/ReactionUserStore.js
@@ -1,4 +1,6 @@
const DataStore = require('./DataStore');
+const { Error } = require('../errors');
+
/**
* A data store to store User models who reacted to a MessageReaction.
* @extends {DataStore}
@@ -23,11 +25,33 @@ class ReactionUserStore extends DataStore {
.reactions[this.reaction.emoji.identifier]
.get({ query: { limit, before, after } });
for (const rawUser of users) {
- const user = this.client.users.create(rawUser);
+ const user = this.client.users.add(rawUser);
this.set(user.id, user);
}
return this;
}
+
+ /**
+ * Removes a user from this reaction.
+ * @param {UserResolvable} [user=this.reaction.message.client.user] The user to remove the reaction of
+ * @returns {Promise}
+ */
+ remove(user = this.reaction.message.client.user) {
+ const message = this.reaction.message;
+ const userID = message.client.users.resolveID(user);
+ if (!userID) return Promise.reject(new Error('REACTION_RESOLVE_USER'));
+ return message.client.api.channels[message.channel.id].messages[message.id]
+ .reactions[this.reaction.emoji.identifier][userID === message.client.user.id ? '@me' : userID]
+ .delete()
+ .then(() =>
+ message.client.actions.MessageReactionRemove.handle({
+ user_id: userID,
+ message_id: message.id,
+ emoji: this.reaction.emoji,
+ channel_id: message.channel.id,
+ }).reaction
+ );
+ }
}
module.exports = ReactionUserStore;
diff --git a/src/stores/RoleStore.js b/src/stores/RoleStore.js
index bb8cd749d..5619360e2 100644
--- a/src/stores/RoleStore.js
+++ b/src/stores/RoleStore.js
@@ -1,9 +1,10 @@
const DataStore = require('./DataStore');
const Role = require('../structures/Role');
+const { resolveColor } = require('../util/Util');
+const Permissions = require('../util/Permissions');
/**
* Stores roles.
- * @private
* @extends {DataStore}
*/
class RoleStore extends DataStore {
@@ -12,8 +13,46 @@ class RoleStore extends DataStore {
this.guild = guild;
}
- create(data, cache) {
- return super.create(data, cache, { extras: [this.guild] });
+ add(data, cache) {
+ return super.add(data, cache, { extras: [this.guild] });
+ }
+
+ /**
+ * Creates a new role in the guild with given information.
+ * The position will silently reset to 1 if an invalid one is provided, or none.
+ * @param {Object} [options] Options
+ * @param {RoleData} [options.data] The data to update the role with
+ * @param {string} [options.reason] Reason for creating this role
+ * @returns {Promise}
+ * @example
+ * // Create a new role
+ * guild.roles.create()
+ * .then(console.log)
+ * .catch(console.error);
+ * @example
+ * // Create a new role with data and a reason
+ * guild.roles.create({
+ * data: {
+ * name: 'Super Cool People',
+ * color: 'BLUE',
+ * },
+ * reason: 'we needed a role for Super Cool People',
+ * })
+ * .then(console.log)
+ * .catch(console.error);
+ */
+ create({ data = {}, reason } = {}) {
+ if (data.color) data.color = resolveColor(data.color);
+ if (data.permissions) data.permissions = Permissions.resolve(data.permissions);
+
+ return this.guild.client.api.guilds(this.guild.id).roles.post({ data, reason }).then(r => {
+ const { role } = this.client.actions.GuildRoleCreate.handle({
+ guild_id: this.guild.id,
+ role: r,
+ });
+ if (data.position) return role.setPosition(data.position, reason);
+ return role;
+ });
}
/**
@@ -24,22 +63,22 @@ class RoleStore extends DataStore {
*/
/**
- * Resolves a RoleResolvable to a Role object.
- * @method resolve
- * @memberof RoleStore
- * @instance
- * @param {RoleResolvable} role The role resolvable to resolve
- * @returns {?Role}
- */
+ * Resolves a RoleResolvable to a Role object.
+ * @method resolve
+ * @memberof RoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Role}
+ */
/**
- * Resolves a RoleResolvable to a role ID string.
- * @method resolveID
- * @memberof RoleStore
- * @instance
- * @param {RoleResolvable} role The role resolvable to resolve
- * @returns {?Snowflake}
- */
+ * Resolves a RoleResolvable to a role ID string.
+ * @method resolveID
+ * @memberof RoleStore
+ * @instance
+ * @param {RoleResolvable} role The role resolvable to resolve
+ * @returns {?Snowflake}
+ */
}
module.exports = RoleStore;
diff --git a/src/stores/UserStore.js b/src/stores/UserStore.js
index 432a0768a..48e988405 100644
--- a/src/stores/UserStore.js
+++ b/src/stores/UserStore.js
@@ -54,7 +54,7 @@ class UserStore extends DataStore {
const existing = this.get(id);
if (existing) return Promise.resolve(existing);
- return this.client.api.users(id).get().then(data => this.create(data, cache));
+ return this.client.api.users(id).get().then(data => this.add(data, cache));
}
}
diff --git a/src/structures/CategoryChannel.js b/src/structures/CategoryChannel.js
index d7121a32b..5766a4a75 100644
--- a/src/structures/CategoryChannel.js
+++ b/src/structures/CategoryChannel.js
@@ -6,13 +6,26 @@ const GuildChannel = require('./GuildChannel');
*/
class CategoryChannel extends GuildChannel {
/**
- * Channels that are part of this category
+ * Channels that are a part of this category
* @type {?Collection}
* @readonly
*/
get children() {
return this.guild.channels.filter(c => c.parentID === this.id);
}
+
+ /**
+ * Sets the category parent of this channel.
+ * It is not currently possible to set the parent of a CategoryChannel.
+ * @method setParent
+ * @memberof CategoryChannel
+ * @instance
+ * @param {?GuildChannel|Snowflake} channel Parent channel
+ * @param {Object} [options={}] Options to pass
+ * @param {boolean} [options.lockPermissions=true] Lock the permissions to what the parent's permissions are
+ * @param {string} [options.reason] Reason for modifying the parent of this channel
+ * @returns {Promise}
+ */
}
module.exports = CategoryChannel;
diff --git a/src/structures/Channel.js b/src/structures/Channel.js
index 04867b118..49248275a 100644
--- a/src/structures/Channel.js
+++ b/src/structures/Channel.js
@@ -52,14 +52,25 @@ class Channel extends Base {
return new Date(this.createdTimestamp);
}
+ /**
+ * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object.
+ * @returns {string}
+ * @example
+ * // Logs: Hello from <#123456789012345678>!
+ * console.log(`Hello from ${channel}!`);
+ */
+ toString() {
+ return `<#${this.id}>`;
+ }
+
/**
* Deletes this channel.
* @returns {Promise}
* @example
* // Delete the channel
* channel.delete()
- * .then() // Success
- * .catch(console.error); // Log error
+ * .then(console.log)
+ * .catch(console.error);
*/
delete() {
return this.client.api.channels(this.id).delete().then(() => this);
diff --git a/src/structures/ClientApplication.js b/src/structures/ClientApplication.js
index 073fce7ba..8c968b0da 100644
--- a/src/structures/ClientApplication.js
+++ b/src/structures/ClientApplication.js
@@ -97,7 +97,7 @@ class ClientApplication extends Base {
* The owner of this OAuth application
* @type {?User}
*/
- this.owner = this.client.users.create(data.owner);
+ this.owner = this.client.users.add(data.owner);
}
}
@@ -166,13 +166,12 @@ class ClientApplication extends Base {
* @param {string} type Type of the asset. `big`, or `small`
* @returns {Promise}
*/
- createAsset(name, data, type) {
- return DataResolver.resolveBase64(data).then(b64 =>
- this.client.api.oauth2.applications(this.id).assets.post({ data: {
- name,
- data: b64,
- type: ClientApplicationAssetTypes[type.toUpperCase()],
- } }));
+ async createAsset(name, data, type) {
+ return this.client.api.oauth2.applications(this.id).assets.post({ data: {
+ name,
+ type: ClientApplicationAssetTypes[type.toUpperCase()],
+ image: await DataResolver.resolveImage(data),
+ } });
}
/**
diff --git a/src/structures/ClientUser.js b/src/structures/ClientUser.js
index b0b3452d4..dd227c31c 100644
--- a/src/structures/ClientUser.js
+++ b/src/structures/ClientUser.js
@@ -2,7 +2,6 @@ const Structures = require('../util/Structures');
const Collection = require('../util/Collection');
const ClientUserSettings = require('./ClientUserSettings');
const ClientUserGuildSettings = require('./ClientUserGuildSettings');
-const { Events } = require('../util/Constants');
const Util = require('../util/Util');
const DataResolver = require('../util/DataResolver');
const Guild = require('./Guild');
@@ -23,7 +22,8 @@ class ClientUser extends Structures.get('User') {
/**
* The email of this account
- * @type {string}
+ * This is only filled when using a user account.
+ * @type {?string}
*/
this.email = data.email;
this._typing = new Map();
@@ -188,7 +188,9 @@ class ClientUser extends Structures.get('User') {
* @typedef {Object} PresenceData
* @property {PresenceStatus} [status] Status of the user
* @property {boolean} [afk] Whether the user is AFK
- * @property {Object} [activity] activity the user is playing
+ * @property {Object} [activity] Activity the user is playing
+ * @property {Object|string} [activity.application] An application object or application id
+ * @property {string} [activity.application.id] The id of the application
* @property {string} [activity.name] Name of the activity
* @property {ActivityType|number} [activity.type] Type of the activity
* @property {string} [activity.url] Stream url
@@ -260,45 +262,7 @@ class ClientUser extends Structures.get('User') {
Util.mergeDefault({ limit: 25, roles: true, everyone: true, guild: null }, options);
return this.client.api.users('@me').mentions.get({ query: options })
- .then(data => data.map(m => this.client.channels.get(m.channel_id).messages.create(m, false)));
- }
-
- /**
- * Creates a guild.
- * This is only available when using a user account.
- * @param {string} name The name of the guild
- * @param {Object} [options] Options for the creating
- * @param {string} [options.region] The region for the server, defaults to the closest one available
- * @param {BufferResolvable|Base64Resolvable} [options.icon=null] The icon for the guild
- * @returns {Promise} The guild that was created
- */
- createGuild(name, { region, icon = null } = {}) {
- if (!icon || (typeof icon === 'string' && icon.startsWith('data:'))) {
- return new Promise((resolve, reject) =>
- this.client.api.guilds.post({ data: { name, region, icon } })
- .then(data => {
- if (this.client.guilds.has(data.id)) return resolve(this.client.guilds.get(data.id));
-
- const handleGuild = guild => {
- if (guild.id === data.id) {
- this.client.removeListener(Events.GUILD_CREATE, handleGuild);
- this.client.clearTimeout(timeout);
- resolve(guild);
- }
- };
- this.client.on(Events.GUILD_CREATE, handleGuild);
-
- const timeout = this.client.setTimeout(() => {
- this.client.removeListener(Events.GUILD_CREATE, handleGuild);
- resolve(this.client.guilds.create(data));
- }, 10000);
- return undefined;
- }, reject)
- );
- }
-
- return DataResolver.resolveImage(icon)
- .then(data => this.createGuild(name, { region, icon: data || null }));
+ .then(data => data.map(m => this.client.channels.get(m.channel_id).messages.add(m, false)));
}
/**
@@ -326,7 +290,7 @@ class ClientUser extends Structures.get('User') {
}, {}),
} : { recipients: recipients.map(u => this.client.users.resolveID(u.user || u.id)) };
return this.client.api.users('@me').channels.post({ data })
- .then(res => this.client.channels.create(res));
+ .then(res => this.client.channels.add(res));
}
}
diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js
index fe6ba57c6..0567b98d4 100644
--- a/src/structures/DMChannel.js
+++ b/src/structures/DMChannel.js
@@ -21,7 +21,7 @@ class DMChannel extends Channel {
* The recipient on the other end of the DM
* @type {User}
*/
- this.recipient = this.client.users.create(data.recipients[0]);
+ this.recipient = this.client.users.add(data.recipients[0]);
this.lastMessageID = data.last_message_id;
}
diff --git a/src/structures/Emoji.js b/src/structures/Emoji.js
index d1cca2843..f5682046f 100644
--- a/src/structures/Emoji.js
+++ b/src/structures/Emoji.js
@@ -1,91 +1,29 @@
-const Collection = require('../util/Collection');
-const Snowflake = require('../util/Snowflake');
const Base = require('./Base');
-const { TypeError } = require('../errors');
/**
- * Represents a custom emoji.
+ * Represents an emoji, see {@link GuildEmoji} and {@link ReactionEmoji}.
* @extends {Base}
*/
class Emoji extends Base {
- constructor(client, data, guild) {
+ constructor(client, emoji) {
super(client);
-
/**
- * The guild this emoji is part of
- * @type {Guild}
+ * Whether this emoji is animated
+ * @type {boolean}
*/
- this.guild = guild;
-
- this._patch(data);
- }
-
- _patch(data) {
- /**
- * The ID of the emoji
- * @type {Snowflake}
- */
- this.id = data.id;
+ this.animated = emoji.animated;
/**
- * The name of the emoji
+ * The name of this emoji
* @type {string}
*/
- this.name = data.name;
+ this.name = emoji.name;
/**
- * Whether or not this emoji requires colons surrounding it
- * @type {boolean}
+ * The ID of this emoji
+ * @type {?Snowflake}
*/
- this.requiresColons = data.require_colons;
-
- /**
- * Whether this emoji is managed by an external service
- * @type {boolean}
- */
- this.managed = data.managed;
-
- this._roles = data.roles;
- }
-
- /**
- * The timestamp the emoji was created at
- * @type {number}
- * @readonly
- */
- get createdTimestamp() {
- return Snowflake.deconstruct(this.id).timestamp;
- }
-
- /**
- * The time the emoji was created at
- * @type {Date}
- * @readonly
- */
- get createdAt() {
- return new Date(this.createdTimestamp);
- }
-
- /**
- * A collection of roles this emoji is active for (empty if all), mapped by role ID
- * @type {Collection}
- * @readonly
- */
- get roles() {
- const roles = new Collection();
- for (const role of this._roles) {
- if (this.guild.roles.has(role)) roles.set(role, this.guild.roles.get(role));
- }
- return roles;
- }
-
- /**
- * The URL to the emoji file
- * @type {string}
- * @readonly
- */
- get url() {
- return this.client.rest.cdn.Emoji(this.id);
+ this.id = emoji.id;
}
/**
@@ -94,144 +32,34 @@ class Emoji extends Base {
* @readonly
*/
get identifier() {
- if (this.id) return `${this.name}:${this.id}`;
+ if (this.id) return `${this.animated ? 'a:' : ''}${this.name}:${this.id}`;
return encodeURIComponent(this.name);
}
/**
- * Data for editing an emoji.
- * @typedef {Object} EmojiEditData
- * @property {string} [name] The name of the emoji
- * @property {Collection|RoleResolvable[]} [roles] Roles to restrict emoji to
+ * The URL to the emoji file if its a custom emoji
+ * @type {?string}
+ * @readonly
*/
-
- /**
- * Edits the emoji.
- * @param {EmojiEditData} data The new data for the emoji
- * @param {string} [reason] Reason for editing this emoji
- * @returns {Promise}
- * @example
- * // Edit an emoji
- * emoji.edit({name: 'newemoji'})
- * .then(e => console.log(`Edited emoji ${e}`))
- * .catch(console.error);
- */
- edit(data, reason) {
- return this.client.api.guilds(this.guild.id).emojis(this.id)
- .patch({ data: {
- name: data.name,
- roles: data.roles ? data.roles.map(r => r.id ? r.id : r) : undefined,
- }, reason })
- .then(() => this);
+ get url() {
+ if (!this.id) return null;
+ return this.client.rest.cdn.Emoji(this.id, this.animated ? 'gif' : 'png');
}
/**
- * Sets the name of the emoji.
- * @param {string} name The new name for the emoji
- * @param {string} [reason] Reason for changing the emoji's name
- * @returns {Promise}
- */
- setName(name, reason) {
- return this.edit({ name }, reason);
- }
-
- /**
- * Adds a role to the list of roles that can use this emoji.
- * @param {Role} role The role to add
- * @returns {Promise}
- */
- addRestrictedRole(role) {
- return this.addRestrictedRoles([role]);
- }
-
- /**
- * Adds multiple roles to the list of roles that can use this emoji.
- * @param {Collection|RoleResolvable[]} roles Roles to add
- * @returns {Promise}
- */
- addRestrictedRoles(roles) {
- const newRoles = new Collection(this.roles);
- for (let role of roles instanceof Collection ? roles.values() : roles) {
- role = this.guild.roles.resolve(role);
- if (!role) {
- return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
- 'Array or Collection of Roles or Snowflakes', true));
- }
- newRoles.set(role.id, role);
- }
- return this.edit({ roles: newRoles });
- }
-
- /**
- * Removes a role from the list of roles that can use this emoji.
- * @param {Role} role The role to remove
- * @returns {Promise}
- */
- removeRestrictedRole(role) {
- return this.removeRestrictedRoles([role]);
- }
-
- /**
- * Removes multiple roles from the list of roles that can use this emoji.
- * @param {Collection|RoleResolvable[]} roles Roles to remove
- * @returns {Promise}
- */
- removeRestrictedRoles(roles) {
- const newRoles = new Collection(this.roles);
- for (let role of roles instanceof Collection ? roles.values() : roles) {
- role = this.guild.roles.resolve(role);
- if (!role) {
- return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
- 'Array or Collection of Roles or Snowflakes', true));
- }
- if (newRoles.has(role.id)) newRoles.delete(role.id);
- }
- return this.edit({ roles: newRoles });
- }
-
- /**
- * When concatenated with a string, this automatically concatenates the emoji's mention instead of the Emoji object.
+ * When concatenated with a string, this automatically returns the text required to form a graphical emoji on Discord
+ * instead of the Emoji object.
* @returns {string}
* @example
- * // Send an emoji:
+ * // Send a custom emoji from a guild:
* const emoji = guild.emojis.first();
* msg.reply(`Hello! ${emoji}`);
+ * @example
+ * // Send the emoji used in a reaction to the channel the reaction is part of
+ * reaction.message.channel.send(`The emoji used was: ${reaction.emoji}`);
*/
toString() {
- return this.requiresColons ? `<:${this.name}:${this.id}>` : this.name;
- }
-
- /**
- * Deletes the emoji.
- * @param {string} [reason] Reason for deleting the emoji
- * @returns {Promise}
- */
- delete(reason) {
- return this.client.api.guilds(this.guild.id).emojis(this.id).delete({ reason })
- .then(() => this);
- }
-
- /**
- * Whether this emoji is the same as another one.
- * @param {Emoji|Object} other The emoji to compare it to
- * @returns {boolean} Whether the emoji is equal to the given emoji or not
- */
- equals(other) {
- if (other instanceof Emoji) {
- return (
- other.id === this.id &&
- other.name === this.name &&
- other.managed === this.managed &&
- other.requiresColons === this.requiresColons &&
- other._roles === this._roles
- );
- } else {
- return (
- other.id === this.id &&
- other.name === this.name &&
- other._roles === this._roles
- );
- }
+ return this.id ? `<${this.animated ? 'a' : ''}:${this.name}:${this.id}>` : this.name;
}
}
diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js
index 2ade06b06..4868fbbb9 100644
--- a/src/structures/GroupDMChannel.js
+++ b/src/structures/GroupDMChannel.js
@@ -89,7 +89,7 @@ class GroupDMChannel extends Channel {
if (data.recipients) {
for (const recipient of data.recipients) {
- const user = this.client.users.create(recipient);
+ const user = this.client.users.add(recipient);
this.recipients.set(user.id, user);
}
}
diff --git a/src/structures/Guild.js b/src/structures/Guild.js
index 307e5eda2..b24bfec47 100644
--- a/src/structures/Guild.js
+++ b/src/structures/Guild.js
@@ -1,18 +1,16 @@
const Invite = require('./Invite');
const GuildAuditLogs = require('./GuildAuditLogs');
const Webhook = require('./Webhook');
-const GuildMember = require('./GuildMember');
const VoiceRegion = require('./VoiceRegion');
const { ChannelTypes, Events, browser } = require('../util/Constants');
const Collection = require('../util/Collection');
const Util = require('../util/Util');
const DataResolver = require('../util/DataResolver');
const Snowflake = require('../util/Snowflake');
-const Permissions = require('../util/Permissions');
const Shared = require('./shared');
const GuildMemberStore = require('../stores/GuildMemberStore');
const RoleStore = require('../stores/RoleStore');
-const EmojiStore = require('../stores/EmojiStore');
+const GuildEmojiStore = require('../stores/GuildEmojiStore');
const GuildChannelStore = require('../stores/GuildChannelStore');
const PresenceStore = require('../stores/PresenceStore');
const Base = require('./Base');
@@ -42,7 +40,7 @@ class Guild extends Base {
/**
* A collection of roles that are in this guild. The key is the role's ID, the value is the role
- * @type {Collection}
+ * @type {RoleStore}
*/
this.roles = new RoleStore(this);
@@ -181,9 +179,21 @@ class Guild extends Base {
this.available = !data.unavailable;
this.features = data.features || this.features || [];
+ if (data.channels) {
+ this.channels.clear();
+ for (const rawChannel of data.channels) {
+ this.client.channels.add(rawChannel, this);
+ }
+ }
+
+ if (data.roles) {
+ this.roles.clear();
+ for (const role of data.roles) this.roles.add(role);
+ }
+
if (data.members) {
this.members.clear();
- for (const guildUser of data.members) this.members.create(guildUser);
+ for (const guildUser of data.members) this.members.add(guildUser);
}
if (data.owner_id) {
@@ -194,21 +204,9 @@ class Guild extends Base {
this.ownerID = data.owner_id;
}
- if (data.channels) {
- this.channels.clear();
- for (const rawChannel of data.channels) {
- this.client.channels.create(rawChannel, this);
- }
- }
-
- if (data.roles) {
- this.roles.clear();
- for (const role of data.roles) this.roles.create(role);
- }
-
if (data.presences) {
for (const presence of data.presences) {
- this.presences.create(presence);
+ this.presences.add(presence);
}
}
@@ -220,10 +218,10 @@ class Guild extends Base {
if (!this.emojis) {
/**
* A collection of emojis that are in this guild. The key is the emoji's ID, the value is the emoji.
- * @type {EmojiStore}
+ * @type {GuildEmojiStore}
*/
- this.emojis = new EmojiStore(this);
- if (data.emojis) for (const emoji of data.emojis) this.emojis.create(emoji);
+ this.emojis = new GuildEmojiStore(this);
+ if (data.emojis) for (const emoji of data.emojis) this.emojis.add(emoji);
} else {
this.client.actions.GuildEmojisUpdate.handle({
guild_id: this.id,
@@ -457,7 +455,7 @@ class Guild extends Base {
bans.reduce((collection, ban) => {
collection.set(ban.user.id, {
reason: ban.reason,
- user: this.client.users.create(ban.user),
+ user: this.client.users.add(ban.user),
});
return collection;
}, new Collection())
@@ -514,6 +512,11 @@ class Guild extends Base {
* @param {UserResolvable} [options.user] Only show entries involving this user
* @param {AuditLogAction|number} [options.type] Only show entries involving this action type
* @returns {Promise}
+ * @example
+ * // Output audit log entries
+ * guild.fetchAuditLogs()
+ * .then(audit => console.log(audit.entries))
+ * .catch(console.error);
*/
fetchAuditLogs(options = {}) {
if (options.before && options.before instanceof GuildAuditLogs.Entry) options.before = options.before.id;
@@ -560,7 +563,7 @@ class Guild extends Base {
}
}
return this.client.api.guilds(this.id).members(user).put({ data: options })
- .then(data => this.members.create(data));
+ .then(data => this.members.add(data));
}
/**
@@ -818,79 +821,6 @@ class Guild extends Base {
else return settings.addRestrictedGuild(this);
}
- /**
- * Bans a user from the guild.
- * @param {UserResolvable} user The user to ban
- * @param {Object} [options] Options for the ban
- * @param {number} [options.days=0] Number of days of messages to delete
- * @param {string} [options.reason] Reason for banning
- * @returns {Promise} Result object will be resolved as specifically as possible.
- * If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot
- * be resolved, the user ID will be the result.
- * @example
- * // 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);
- */
- ban(user, options = { days: 0 }) {
- if (options.days) options['delete-message-days'] = options.days;
- const id = this.client.users.resolveID(user);
- if (!id) return Promise.reject(new Error('BAN_RESOLVE_ID', true));
- return this.client.api.guilds(this.id).bans[id].put({ query: options })
- .then(() => {
- if (user instanceof GuildMember) return user;
- const _user = this.client.users.resolve(id);
- if (_user) {
- const member = this.members.resolve(_user);
- return member || _user;
- }
- return id;
- });
- }
-
- /**
- * Unbans a user from the guild.
- * @param {UserResolvable} user The user to unban
- * @param {string} [reason] Reason for unbanning 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);
- */
- unban(user, reason) {
- const id = this.client.users.resolveID(user);
- if (!id) throw new Error('BAN_RESOLVE_ID');
- return this.client.api.guilds(this.id).bans[id].delete({ reason })
- .then(() => user);
- }
-
- /**
- * Prunes members from the guild based on how long they have been inactive.
- * @param {Object} [options] Prune options
- * @param {number} [options.days=7] Number of days of inactivity required to kick
- * @param {boolean} [options.dry=false] Get number of users that will be kicked, without actually kicking them
- * @param {string} [options.reason] Reason for this prune
- * @returns {Promise} The number of members that were/will be kicked
- * @example
- * // See how many members will be pruned
- * guild.pruneMembers({ dry: true })
- * .then(pruned => console.log(`This will prune ${pruned} people!`))
- * .catch(console.error);
- * @example
- * // Actually prune the members
- * guild.pruneMembers({ days: 1, reason: 'too many people!' })
- * .then(pruned => console.log(`I just pruned ${pruned} people!`))
- * .catch(console.error);
- */
- pruneMembers({ days = 7, dry = false, reason } = {}) {
- if (typeof days !== 'number') throw new TypeError('PRUNE_DAYS_TYPE');
- return this.client.api.guilds(this.id).prune[dry ? 'get' : 'post']({ query: { days }, reason })
- .then(data => data.pruned);
- }
-
/**
* Syncs this guild (already done automatically every 30 seconds).
* This is only available when using a user account.
@@ -899,73 +829,6 @@ class Guild extends Base {
if (!this.client.user.bot) this.client.syncGuilds([this]);
}
- /**
- * Can be used to overwrite permissions when creating a channel.
- * @typedef {Object} ChannelCreationOverwrites
- * @property {PermissionResolvable[]|number} [allow] The permissions to allow
- * @property {PermissionResolvable[]|number} [deny] The permissions to deny
- * @property {RoleResolvable|UserResolvable} id ID of the role or member this overwrite is for
- */
-
- /**
- * 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`, `voice`, or `category`
- * @param {Object} [options] Options
- * @param {boolean} [options.nsfw] Whether the new channel is nsfw
- * @param {number} [options.bitrate] Bitrate of the new channel in bits (only voice)
- * @param {number} [options.userLimit] Maximum amount of users allowed in the new channel (only voice)
- * @param {ChannelResolvable} [options.parent] Parent of the new channel
- * @param {Array} [options.overwrites] Permission overwrites
- * @param {string} [options.reason] Reason for creating the 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);
- */
- createChannel(name, type, { nsfw, bitrate, userLimit, parent, overwrites, reason } = {}) {
- if (overwrites instanceof Collection || overwrites instanceof Array) {
- overwrites = overwrites.map(overwrite => {
- let allow = overwrite.allow || (overwrite.allowed ? overwrite.allowed.bitfield : 0);
- let deny = overwrite.deny || (overwrite.denied ? overwrite.denied.bitfield : 0);
- if (allow instanceof Array) allow = Permissions.resolve(allow);
- if (deny instanceof Array) deny = Permissions.resolve(deny);
-
- const role = this.roles.resolve(overwrite.id);
- if (role) {
- overwrite.id = role.id;
- overwrite.type = 'role';
- } else {
- overwrite.id = this.client.users.resolveID(overwrite.id);
- overwrite.type = 'member';
- }
-
- return {
- allow,
- deny,
- type: overwrite.type,
- id: overwrite.id,
- };
- });
- }
-
- if (parent) parent = this.client.channels.resolveID(parent);
- return this.client.api.guilds(this.id).channels.post({
- data: {
- name,
- type: ChannelTypes[type.toUpperCase()],
- nsfw,
- bitrate,
- user_limit: userLimit,
- parent_id: parent,
- permission_overwrites: overwrites,
- },
- reason,
- }).then(data => this.client.actions.ChannelCreate.handle(data).channel);
- }
-
/**
* The data needed for updating a channel's position.
* @typedef {Object} ChannelPosition
@@ -996,85 +859,6 @@ class Guild extends Base {
);
}
- /**
- * Creates a new role in the guild with given information.
- * The position will silently reset to 1 if an invalid one is provided, or none.
- * @param {Object} [options] Options
- * @param {RoleData} [options.data] The data to update the role with
- * @param {string} [options.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);
- * @example
- * // Create a new role with data and a reason
- * guild.createRole({
- * data: {
- * name: 'Super Cool People',
- * color: 'BLUE',
- * },
- * reason: 'we needed a role for Super Cool People',
- * })
- * .then(role => console.log(`Created role ${role}`))
- * .catch(console.error);
- */
- createRole({ data = {}, reason } = {}) {
- if (data.color) data.color = Util.resolveColor(data.color);
- if (data.permissions) data.permissions = Permissions.resolve(data.permissions);
-
- return this.client.api.guilds(this.id).roles.post({ data, reason }).then(r => {
- const { role } = this.client.actions.GuildRoleCreate.handle({
- guild_id: this.id,
- role: r,
- });
- if (data.position) return role.setPosition(data.position, reason);
- return role;
- });
- }
-
- /**
- * Creates a new custom emoji in the guild.
- * @param {BufferResolvable|Base64Resolvable} attachment The image for the emoji
- * @param {string} name The name for the emoji
- * @param {Object} [options] Options
- * @param {Collection|RoleResolvable[]} [options.roles] Roles to limit the emoji to
- * @param {string} [options.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);
- * @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);
- */
- createEmoji(attachment, name, { roles, reason } = {}) {
- if (typeof attachment === 'string' && attachment.startsWith('data:')) {
- const data = { image: attachment, name };
- if (roles) {
- data.roles = [];
- for (let role of roles instanceof Collection ? roles.values() : roles) {
- role = this.roles.resolve(role);
- if (!role) {
- return Promise.reject(new TypeError('INVALID_TYPE', 'options.roles',
- 'Array or Collection of Roles or Snowflakes', true));
- }
- data.roles.push(role.id);
- }
- }
-
- return this.client.api.guilds(this.id).emojis.post({ data, reason })
- .then(emoji => this.client.actions.GuildEmojiCreate.handle(this, emoji).emoji);
- }
-
- return DataResolver.resolveImage(attachment).then(image => this.createEmoji(image, name, { roles, reason }));
- }
-
/**
* Leaves the guild.
* @returns {Promise}
diff --git a/src/structures/GuildAuditLogs.js b/src/structures/GuildAuditLogs.js
index d43a48b29..6df77707e 100644
--- a/src/structures/GuildAuditLogs.js
+++ b/src/structures/GuildAuditLogs.js
@@ -106,7 +106,7 @@ const Actions = {
*/
class GuildAuditLogs {
constructor(guild, data) {
- if (data.users) for (const user of data.users) guild.client.users.create(user);
+ if (data.users) for (const user of data.users) guild.client.users.add(user);
/**
* Cached webhooks
* @type {Collection}
@@ -148,7 +148,7 @@ class GuildAuditLogs {
* * An invite
* * A webhook
* * An object where the keys represent either the new value or the old value
- * @typedef {?Object|Guild|User|Role|Emoji|Invite|Webhook} AuditLogEntryTarget
+ * @typedef {?Object|Guild|User|Role|GuildEmoji|Invite|Webhook} AuditLogEntryTarget
*/
/**
diff --git a/src/structures/GuildChannel.js b/src/structures/GuildChannel.js
index a84ad0847..324b6aeda 100644
--- a/src/structures/GuildChannel.js
+++ b/src/structures/GuildChannel.js
@@ -9,7 +9,7 @@ const { MessageNotificationTypes } = require('../util/Constants');
const { Error, TypeError } = require('../errors');
/**
- * Represents a guild channel (e.g. text channels and voice channels).
+ * Represents a guild channel (i.g. a {@link TextChannel}, {@link VoiceChannel} or {@link CategoryChannel}).
* @extends {Channel}
*/
class GuildChannel extends Channel {
@@ -92,31 +92,16 @@ class GuildChannel extends Channel {
}
/**
- * Gets the overall set of permissions for a user in this channel, taking into account roles and permission
- * overwrites.
- * @param {GuildMemberResolvable} member The user that you want to obtain the overall permissions for
+ * Gets the overall set of permissions for a member or role in this channel, taking into account channel overwrites.
+ * @param {GuildMemberResolvable|RoleResolvable} memberOrRole The member or role to obtain the overall permissions for
* @returns {?Permissions}
*/
- permissionsFor(member) {
- member = this.guild.members.resolve(member);
- if (!member) return null;
- if (member.id === this.guild.ownerID) return new Permissions(Permissions.ALL).freeze();
-
- const roles = member.roles;
- const permissions = new Permissions(roles.map(role => role.permissions));
-
- if (permissions.has(Permissions.FLAGS.ADMINISTRATOR)) return new Permissions(Permissions.ALL).freeze();
-
- const overwrites = this.overwritesFor(member, true, roles);
-
- return permissions
- .remove(overwrites.everyone ? overwrites.everyone.denied : 0)
- .add(overwrites.everyone ? overwrites.everyone.allowed : 0)
- .remove(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.denied) : 0)
- .add(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.allowed) : 0)
- .remove(overwrites.member ? overwrites.member.denied : 0)
- .add(overwrites.member ? overwrites.member.allowed : 0)
- .freeze();
+ permissionsFor(memberOrRole) {
+ const member = this.guild.members.resolve(memberOrRole);
+ if (member) return this.memberPermissions(member);
+ const role = this.guild.roles.resolve(memberOrRole);
+ if (role) return this.rolePermissions(role);
+ return null;
}
overwritesFor(member, verified = false, roles = null) {
@@ -145,6 +130,52 @@ class GuildChannel extends Channel {
};
}
+ /**
+ * Gets the overall set of permissions for a member in this channel, taking into account channel overwrites.
+ * @param {GuildMember} member The member to obtain the overall permissions for
+ * @returns {Permissions}
+ * @private
+ */
+ memberPermissions(member) {
+ if (member.id === this.guild.ownerID) return new Permissions(Permissions.ALL).freeze();
+
+ const roles = member.roles;
+ const permissions = new Permissions(roles.map(role => role.permissions));
+
+ if (permissions.has(Permissions.FLAGS.ADMINISTRATOR)) return new Permissions(Permissions.ALL).freeze();
+
+ const overwrites = this.overwritesFor(member, true, roles);
+
+ return permissions
+ .remove(overwrites.everyone ? overwrites.everyone.denied : 0)
+ .add(overwrites.everyone ? overwrites.everyone.allowed : 0)
+ .remove(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.denied) : 0)
+ .add(overwrites.roles.length > 0 ? overwrites.roles.map(role => role.allowed) : 0)
+ .remove(overwrites.member ? overwrites.member.denied : 0)
+ .add(overwrites.member ? overwrites.member.allowed : 0)
+ .freeze();
+ }
+
+ /**
+ * Gets the overall set of permissions for a role in this channel, taking into account channel overwrites.
+ * @param {Role} role The role to obtain the overall permissions for
+ * @returns {Permissions}
+ * @private
+ */
+ rolePermissions(role) {
+ if (role.permissions.has(Permissions.FLAGS.ADMINISTRATOR)) return new Permissions(Permissions.ALL).freeze();
+
+ const everyoneOverwrites = this.permissionOverwrites.get(this.guild.id);
+ const roleOverwrites = this.permissionOverwrites.get(role.id);
+
+ return role.permissions
+ .remove(everyoneOverwrites ? everyoneOverwrites.denied : 0)
+ .add(everyoneOverwrites ? everyoneOverwrites.allowed : 0)
+ .remove(roleOverwrites ? roleOverwrites.denied : 0)
+ .add(roleOverwrites ? roleOverwrites.allowed : 0)
+ .freeze();
+ }
+
/**
* An object mapping permission flags to `true` (enabled), `null` (default) or `false` (disabled).
* ```js
@@ -272,8 +303,8 @@ class GuildChannel extends Channel {
* @returns {Promise}
* @example
* // Edit a channel
- * channel.edit({name: 'new-channel'})
- * .then(c => console.log(`Edited channel ${c}`))
+ * channel.edit({ name: 'new-channel' })
+ * .then(console.log)
* .catch(console.error);
*/
async edit(data, reason) {
@@ -292,7 +323,7 @@ class GuildChannel extends Channel {
name: (data.name || this.name).trim(),
topic: data.topic,
nsfw: data.nsfw,
- bitrate: data.bitrate || (this.bitrate ? this.bitrate * 1000 : undefined),
+ bitrate: data.bitrate || this.bitrate,
user_limit: typeof data.userLimit !== 'undefined' ? data.userLimit : this.userLimit,
parent_id: data.parentID,
lock_permissions: data.lockPermissions,
@@ -323,14 +354,15 @@ class GuildChannel extends Channel {
/**
* Sets the category parent of this channel.
- * @param {GuildChannel|Snowflake} channel Parent channel
- * @param {boolean} [options.lockPermissions] Lock the permissions to what the parent's permissions are
+ * @param {?GuildChannel|Snowflake} channel Parent channel
+ * @param {Object} [options={}] Options to pass
+ * @param {boolean} [options.lockPermissions=true] Lock the permissions to what the parent's permissions are
* @param {string} [options.reason] Reason for modifying the parent of this channel
* @returns {Promise}
*/
setParent(channel, { lockPermissions = true, reason } = {}) {
return this.edit({
- parentID: channel.id ? channel.id : channel,
+ parentID: channel !== null ? channel.id ? channel.id : channel : null,
lockPermissions,
}, reason);
}
@@ -385,6 +417,11 @@ class GuildChannel extends Channel {
* @param {boolean} [options.unique=false] Create a unique invite, or use an existing one with similar settings
* @param {string} [options.reason] Reason for creating this
* @returns {Promise}
+ * @example
+ * // Create an invite to a channel
+ * channel.createInvite()
+ * .then(invite => console.log(`Created an invite with a code of ${invite.code}`))
+ * .catch(console.error);
*/
createInvite({ temporary = false, maxAge = 86400, maxUses = 0, unique, reason } = {}) {
return this.client.api.channels(this.id).invites.post({ data: {
@@ -393,6 +430,21 @@ class GuildChannel extends Channel {
.then(invite => new Invite(this.client, invite));
}
+ /**
+ * Fetches a collection of invites to this guild channel.
+ * Resolves with a collection mapping invites by their codes.
+ * @returns {Promise>}
+ */
+ async fetchInvites() {
+ const inviteItems = await this.client.api.channels(this.id).invites.get();
+ const invites = new Collection();
+ for (const inviteItem of inviteItems) {
+ const invite = new Invite(this.client, inviteItem);
+ invites.set(invite.code, invite);
+ }
+ return invites;
+ }
+
/**
* Clones this channel.
* @param {Object} [options] The options
@@ -401,13 +453,28 @@ class GuildChannel extends Channel {
* @param {boolean} [options.withPermissions=true] Whether to clone the channel with this channel's
* permission overwrites
* @param {boolean} [options.withTopic=true] Whether to clone the channel with this channel's topic
+ * @param {boolean} [options.nsfw=this.nsfw] Whether the new channel is nsfw (only text)
+ * @param {number} [options.bitrate=this.bitrate] Bitrate of the new channel in bits (only voice)
+ * @param {number} [options.userLimit=this.userLimit] Maximum amount of users allowed in the new channel (only voice)
+ * @param {ChannelResolvable} [options.parent=this.parent] The parent of the new channel
* @param {string} [options.reason] Reason for cloning this channel
* @returns {Promise}
*/
- clone({ name = this.name, withPermissions = true, withTopic = true, reason } = {}) {
- const options = { overwrites: withPermissions ? this.permissionOverwrites : [], reason };
- return this.guild.createChannel(name, this.type, options)
- .then(channel => withTopic ? channel.setTopic(this.topic) : channel);
+ clone(options = {}) {
+ if (typeof options.withPermissions === 'undefined') options.withPermissions = true;
+ Util.mergeDefault({
+ name: this.name,
+ overwrites: options.withPermissions ? this.permissionOverwrites : [],
+ withTopic: true,
+ nsfw: this.nsfw,
+ parent: this.parent,
+ bitrate: this.bitrate,
+ userLimit: this.userLimit,
+ reason: null,
+ }, options);
+ options.type = this.type;
+ return this.guild.channels.create(options.name, options)
+ .then(channel => options.withTopic ? channel.setTopic(this.topic) : channel);
}
/**
@@ -488,17 +555,6 @@ class GuildChannel extends Channel {
return MessageNotificationTypes[3];
}
}
-
- /**
- * When concatenated with a string, this automatically returns the channel's mention instead of the Channel object.
- * @returns {string}
- * @example
- * // Logs: Hello from <#123456789012345678>!
- * console.log(`Hello from ${channel}!`);
- */
- toString() {
- return `<#${this.id}>`;
- }
}
module.exports = GuildChannel;
diff --git a/src/structures/GuildEmoji.js b/src/structures/GuildEmoji.js
new file mode 100644
index 000000000..7568738ae
--- /dev/null
+++ b/src/structures/GuildEmoji.js
@@ -0,0 +1,144 @@
+const GuildEmojiRoleStore = require('../stores/GuildEmojiRoleStore');
+const Snowflake = require('../util/Snowflake');
+const Emoji = require('./Emoji');
+
+/**
+ * Represents a custom emoji.
+ * @extends {Emoji}
+ */
+class GuildEmoji extends Emoji {
+ constructor(client, data, guild) {
+ super(client, data);
+
+ /**
+ * The guild this emoji is part of
+ * @type {Guild}
+ */
+ this.guild = guild;
+
+ /**
+ * A collection of roles this emoji is active for (empty if all), mapped by role ID
+ * @type {GuildEmojiRoleStore}
+ */
+ this.roles = new GuildEmojiRoleStore(this);
+
+ this._patch(data);
+ }
+
+ _patch(data) {
+ this.name = data.name;
+
+ /**
+ * Whether or not this emoji requires colons surrounding it
+ * @type {boolean}
+ */
+ this.requiresColons = data.require_colons;
+
+ /**
+ * Whether this emoji is managed by an external service
+ * @type {boolean}
+ */
+ this.managed = data.managed;
+
+ if (data.roles) this.roles._patch(data.roles);
+ }
+
+ /**
+ * The timestamp the emoji was created at
+ * @type {number}
+ * @readonly
+ */
+ get createdTimestamp() {
+ return Snowflake.deconstruct(this.id).timestamp;
+ }
+
+ /**
+ * The time the emoji was created at
+ * @type {Date}
+ * @readonly
+ */
+ get createdAt() {
+ return new Date(this.createdTimestamp);
+ }
+
+ /**
+ * Fetches the author for this emoji
+ * @returns {Promise}
+ */
+ fetchAuthor() {
+ return this.client.api.guilds(this.guild.id).emojis(this.id).get()
+ .then(emoji => this.client.users.add(emoji.user));
+ }
+
+ /**
+ * Data for editing an emoji.
+ * @typedef {Object} GuildEmojiEditData
+ * @property {string} [name] The name of the emoji
+ * @property {Collection|RoleResolvable[]} [roles] Roles to restrict emoji to
+ */
+
+ /**
+ * Edits the emoji.
+ * @param {Guild} data The new data for the emoji
+ * @param {string} [reason] Reason for editing this emoji
+ * @returns {Promise}
+ * @example
+ * // Edit an emoji
+ * emoji.edit({name: 'newemoji'})
+ * .then(e => console.log(`Edited emoji ${e}`))
+ * .catch(console.error);
+ */
+ edit(data, reason) {
+ return this.client.api.guilds(this.guild.id).emojis(this.id)
+ .patch({ data: {
+ name: data.name,
+ roles: data.roles ? data.roles.map(r => r.id ? r.id : r) : undefined,
+ }, reason })
+ .then(() => this);
+ }
+
+ /**
+ * Sets the name of the emoji.
+ * @param {string} name The new name for the emoji
+ * @param {string} [reason] Reason for changing the emoji's name
+ * @returns {Promise}
+ */
+ setName(name, reason) {
+ return this.edit({ name }, reason);
+ }
+
+ /**
+ * Deletes the emoji.
+ * @param {string} [reason] Reason for deleting the emoji
+ * @returns {Promise}
+ */
+ delete(reason) {
+ return this.client.api.guilds(this.guild.id).emojis(this.id).delete({ reason })
+ .then(() => this);
+ }
+
+ /**
+ * Whether this emoji is the same as another one.
+ * @param {GuildEmoji|Object} other The emoji to compare it to
+ * @returns {boolean} Whether the emoji is equal to the given emoji or not
+ */
+ equals(other) {
+ if (other instanceof GuildEmoji) {
+ return (
+ other.id === this.id &&
+ other.name === this.name &&
+ other.managed === this.managed &&
+ other.requiresColons === this.requiresColons &&
+ other.roles.every(role => this.roles.has(role.id))
+ );
+ } else {
+ return (
+ other.id === this.id &&
+ other.name === this.name &&
+ other.roles.every(role => this.roles.has(role))
+ );
+ }
+ }
+}
+
+module.exports = GuildEmoji;
diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js
index 21f84ec98..1b988d32f 100644
--- a/src/structures/GuildMember.js
+++ b/src/structures/GuildMember.js
@@ -1,10 +1,10 @@
const TextBasedChannel = require('./interfaces/TextBasedChannel');
const Role = require('./Role');
const Permissions = require('../util/Permissions');
-const Collection = require('../util/Collection');
+const GuildMemberRoleStore = require('../stores/GuildMemberRoleStore');
const Base = require('./Base');
const { Presence } = require('./Presence');
-const { Error, TypeError } = require('../errors');
+const { Error } = require('../errors');
/**
* Represents a member of a guild on Discord.
@@ -22,12 +22,17 @@ class GuildMember extends Base {
this.guild = guild;
/**
- * The user that this guild member instance Represents
+ * The user that this guild member instance represents
* @type {User}
*/
this.user = {};
- this._roles = [];
+ /**
+ * A list of roles that are applied to this GuildMember, mapped by the role ID
+ * @type {GuildMemberRoleStore}
+ */
+
+ this.roles = new GuildMemberRoleStore(this);
if (data) this._patch(data);
@@ -66,8 +71,14 @@ class GuildMember extends Base {
*/
if (data.joined_at) this.joinedTimestamp = new Date(data.joined_at).getTime();
- this.user = this.guild.client.users.create(data.user);
- if (data.roles) this._roles = data.roles;
+ this.user = this.guild.client.users.add(data.user);
+ if (data.roles) this.roles._patch(data.roles);
+ }
+
+ _clone() {
+ const clone = super._clone();
+ clone.roles = this.roles.clone();
+ return clone;
}
get voiceState() {
@@ -134,52 +145,13 @@ class GuildMember extends Base {
return this.frozenPresence || this.guild.presences.get(this.id) || new Presence(this.client);
}
- /**
- * A list of roles that are applied to this GuildMember, mapped by the role ID
- * @type {Collection}
- * @readonly
- */
- get roles() {
- const list = new Collection();
- const everyoneRole = this.guild.roles.get(this.guild.id);
-
- if (everyoneRole) list.set(everyoneRole.id, everyoneRole);
-
- for (const roleID of this._roles) {
- const role = this.guild.roles.get(roleID);
- if (role) list.set(role.id, role);
- }
-
- return list;
- }
-
- /**
- * The role of the member with the highest position
- * @type {Role}
- * @readonly
- */
- get highestRole() {
- return this.roles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
- }
-
- /**
- * The role of the member used to set their color
- * @type {?Role}
- * @readonly
- */
- get colorRole() {
- const coloredRoles = this.roles.filter(role => role.color);
- if (!coloredRoles.size) return null;
- return coloredRoles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
- }
-
/**
* The displayed color of the member in base 10
* @type {number}
* @readonly
*/
get displayColor() {
- const role = this.colorRole;
+ const role = this.roles.color;
return (role && role.color) || 0;
}
@@ -189,21 +161,10 @@ class GuildMember extends Base {
* @readonly
*/
get displayHexColor() {
- const role = this.colorRole;
+ const role = this.roles.color;
return (role && role.hexColor) || '#000000';
}
- /**
- * The role of the member used to hoist them in a separate category in the users list
- * @type {?Role}
- * @readonly
- */
- get hoistRole() {
- const hoistedRoles = this.roles.filter(role => role.hoist);
- if (!hoistedRoles.size) return null;
- return hoistedRoles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
- }
-
/**
* Whether this member is muted in any way
* @type {boolean}
@@ -259,17 +220,24 @@ class GuildMember extends Base {
return new Permissions(this.roles.map(role => role.permissions)).freeze();
}
+ /**
+ * Whether the member is manageable in terms of role hierarchy by the client user
+ * @type {boolean}
+ * @readonly
+ */
+ get manageable() {
+ if (this.user.id === this.guild.ownerID) return false;
+ if (this.user.id === this.client.user.id) return false;
+ return this.guild.me.roles.highest.comparePositionTo(this.roles.highest) > 0;
+ }
+
/**
* Whether the member is kickable by the client user
* @type {boolean}
* @readonly
*/
get kickable() {
- if (this.user.id === this.guild.ownerID) return false;
- if (this.user.id === this.client.user.id) return false;
- const clientMember = this.guild.member(this.client.user);
- if (!clientMember.permissions.has(Permissions.FLAGS.KICK_MEMBERS)) return false;
- return clientMember.highestRole.comparePositionTo(this.highestRole) > 0;
+ return this.manageable && this.guild.me.permissions.has(Permissions.FLAGS.KICK_MEMBERS);
}
/**
@@ -278,11 +246,7 @@ class GuildMember extends Base {
* @readonly
*/
get bannable() {
- if (this.user.id === this.guild.ownerID) return false;
- if (this.user.id === this.client.user.id) return false;
- const clientMember = this.guild.member(this.client.user);
- if (!clientMember.permissions.has(Permissions.FLAGS.BAN_MEMBERS)) return false;
- return clientMember.highestRole.comparePositionTo(this.highestRole) > 0;
+ return this.manageable && this.guild.me.permissions.has(Permissions.FLAGS.BAN_MEMBERS);
}
/**
@@ -292,19 +256,20 @@ class GuildMember extends Base {
* @returns {?Permissions}
*/
permissionsIn(channel) {
- channel = this.client.channels.resolve(channel);
- if (!channel || !channel.guild) throw new Error('GUILD_CHANNEL_RESOLVE');
- return channel.permissionsFor(this);
+ channel = this.guild.channels.resolve(channel);
+ if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE');
+ return channel.memberPermissions(this);
}
/**
* Checks if any of the member's roles have a permission.
* @param {PermissionResolvable|PermissionResolvable[]} permission Permission(s) to check for
- * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override
- * @param {boolean} [checkOwner=true] Whether to allow being the guild's owner to override
+ * @param {Object} [options] Options
+ * @param {boolean} [options.checkAdmin=true] Whether to allow the administrator permission to override
+ * @param {boolean} [options.checkOwner=true] Whether to allow being the guild's owner to override
* @returns {boolean}
*/
- hasPermission(permission, checkAdmin = true, checkOwner = true) {
+ hasPermission(permission, { checkAdmin = true, checkOwner = true } = {}) {
if (checkOwner && this.user.id === this.guild.ownerID) return true;
return this.roles.some(r => r.permissions.has(permission, checkAdmin));
}
@@ -353,7 +318,8 @@ class GuildMember extends Base {
const clone = this._clone();
data.user = this.user;
clone._patch(data);
- clone._frozenVoiceState = this.voiceState;
+ clone._frozenVoiceState = {};
+ Object.assign(clone._frozenVoiceState, this.voiceState);
if (typeof data.mute !== 'undefined') clone._frozenVoiceState.mute = data.mute;
if (typeof data.deaf !== 'undefined') clone._frozenVoiceState.mute = data.deaf;
if (typeof data.channel_id !== 'undefined') clone._frozenVoiceState.channel_id = data.channel_id;
@@ -362,7 +328,7 @@ class GuildMember extends Base {
}
/**
- * Mute/unmutes a user.
+ * Mutes/unmutes a user.
* @param {boolean} mute Whether or not the member should be muted
* @param {string} [reason] Reason for muting or unmuting
* @returns {Promise}
@@ -372,7 +338,7 @@ class GuildMember extends Base {
}
/**
- * Deafen/undeafens a user.
+ * Deafens/undeafens a user.
* @param {boolean} deaf Whether or not the member should be deafened
* @param {string} [reason] Reason for deafening or undeafening
* @returns {Promise}
@@ -390,94 +356,6 @@ class GuildMember extends Base {
return this.edit({ channel });
}
- /**
- * Sets the roles applied to the member.
- * @param {Collection|RoleResolvable[]} roles The roles or role IDs to apply
- * @param {string} [reason] Reason for applying the roles
- * @returns {Promise}
- */
- setRoles(roles, reason) {
- return this.edit({ roles }, reason);
- }
-
- /**
- * Adds a single role to the member.
- * @param {RoleResolvable} role The role or ID of the role to add
- * @param {string} [reason] Reason for adding the role
- * @returns {Promise}
- */
- addRole(role, reason) {
- role = this.guild.roles.resolve(role);
- if (!role) return Promise.reject(new TypeError('INVALID_TYPE', 'role', 'Role nor a Snowflake'));
- if (this._roles.includes(role.id)) return Promise.resolve(this);
- return this.client.api.guilds(this.guild.id).members(this.user.id).roles(role.id)
- .put({ reason })
- .then(() => {
- const clone = this._clone();
- if (!clone._roles.includes(role.id)) clone._roles.push(role.id);
- return clone;
- });
- }
-
- /**
- * Adds multiple roles to the member.
- * @param {Collection|RoleResolvable[]} roles The roles or role IDs to add
- * @param {string} [reason] Reason for adding the roles
- * @returns {Promise}
- */
- addRoles(roles, reason) {
- let allRoles = this._roles.slice();
- for (let role of roles instanceof Collection ? roles.values() : roles) {
- role = this.guild.roles.resolve(role);
- if (!role) {
- return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
- 'Array or Collection of Roles or Snowflakes', true));
- }
- allRoles.push(role.id);
- }
- return this.edit({ roles: allRoles }, reason);
- }
-
- /**
- * Removes a single role from the member.
- * @param {RoleResolvable} role The role or ID of the role to remove
- * @param {string} [reason] Reason for removing the role
- * @returns {Promise}
- */
- removeRole(role, reason) {
- role = this.guild.roles.resolve(role);
- if (!role) return Promise.reject(new TypeError('INVALID_TYPE', 'role', 'Role nor a Snowflake'));
- if (!this._roles.includes(role.id)) return Promise.resolve(this);
- return this.client.api.guilds(this.guild.id).members(this.user.id).roles(role.id)
- .delete({ reason })
- .then(() => {
- const clone = this._clone();
- const index = clone._roles.indexOf(role.id);
- if (~index) clone._roles.splice(index, 1);
- return clone;
- });
- }
-
- /**
- * Removes multiple roles from the member.
- * @param {Collection|RoleResolvable[]} roles The roles or role IDs to remove
- * @param {string} [reason] Reason for removing the roles
- * @returns {Promise}
- */
- removeRoles(roles, reason) {
- const allRoles = this._roles.slice();
- for (let role of roles instanceof Collection ? roles.values() : roles) {
- role = this.guild.roles.resolve(role);
- if (!role) {
- return Promise.reject(new TypeError('INVALID_TYPE', 'roles',
- 'Array or Collection of Roles or Snowflakes', true));
- }
- const index = allRoles.indexOf(role.id);
- if (index >= 0) allRoles.splice(index, 1);
- }
- return this.edit({ roles: allRoles }, reason);
- }
-
/**
* Sets the nickname for the guild member.
* @param {string} nick The nickname for the guild member
@@ -511,12 +389,7 @@ class GuildMember extends Base {
*/
kick(reason) {
return this.client.api.guilds(this.guild.id).members(this.user.id).delete({ reason })
- .then(() =>
- this.client.actions.GuildMemberRemove.handle({
- guild_id: this.guild.id,
- user: this.user,
- }).member
- );
+ .then(() => this);
}
/**
@@ -527,10 +400,12 @@ class GuildMember extends Base {
* @returns {Promise}
* @example
* // ban a guild member
- * guildMember.ban(7);
+ * guildMember.ban({ days: 7, reason: 'They deserved it' })
+ * .then(console.log)
+ * .catch(console.error);
*/
ban(options) {
- return this.guild.ban(this, options);
+ return this.guild.members.ban(this, options);
}
/**
diff --git a/src/structures/Invite.js b/src/structures/Invite.js
index 89d5c9c99..1f02d11b7 100644
--- a/src/structures/Invite.js
+++ b/src/structures/Invite.js
@@ -17,7 +17,7 @@ class Invite extends Base {
* The guild the invite is for
* @type {Guild}
*/
- this.guild = this.client.guilds.create(data.guild, false);
+ this.guild = this.client.guilds.add(data.guild, false);
/**
* The code for this invite
@@ -78,14 +78,14 @@ class Invite extends Base {
* The user who created this invite
* @type {User}
*/
- this.inviter = this.client.users.create(data.inviter);
+ this.inviter = this.client.users.add(data.inviter);
}
/**
* The channel the invite is for
* @type {GuildChannel}
*/
- this.channel = this.client.channels.create(data.channel, this.guild, false);
+ this.channel = this.client.channels.add(data.channel, this.guild, false);
/**
* The timestamp the invite was created at
diff --git a/src/structures/Message.js b/src/structures/Message.js
index a409e411a..5b9c74aa5 100644
--- a/src/structures/Message.js
+++ b/src/structures/Message.js
@@ -52,14 +52,7 @@ class Message extends Base {
* The author of the message
* @type {User}
*/
- this.author = this.client.users.create(data.author, !data.webhook_id);
-
- /**
- * 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;
+ this.author = this.client.users.add(data.author, !data.webhook_id);
/**
* Whether or not this message is pinned
@@ -121,7 +114,7 @@ class Message extends Base {
this.reactions = new ReactionStore(this);
if (data.reactions && data.reactions.length > 0) {
for (const reaction of data.reactions) {
- this.reactions.create(reaction);
+ this.reactions.add(reaction);
}
}
@@ -145,7 +138,7 @@ class Message extends Base {
/**
* Group activity
- * @type {?Object}
+ * @type {?MessageActivity}
*/
this.activity = data.activity ? {
partyID: data.activity.party_id,
@@ -201,6 +194,16 @@ class Message extends Base {
);
}
+ /**
+ * 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}
+ * @readonly
+ */
+ get member() {
+ return this.guild ? this.guild.member(this.author) || null : null;
+ }
+
/**
* The time the message was sent at
* @type {Date}
@@ -273,10 +276,8 @@ class Message extends Base {
* @returns {ReactionCollector}
* @example
* // Create a reaction collector
- * const collector = message.createReactionCollector(
- * (reaction, user) => reaction.emoji.name === 'π' && user.id === 'someID',
- * { time: 15000 }
- * );
+ * const filter = (reaction, user) => reaction.emoji.name === 'π' && user.id === 'someID';
+ * const collector = message.createReactionCollector(filter, { time: 15000 });
* collector.on('collect', r => console.log(`Collected ${r.emoji.name}`));
* collector.on('end', collected => console.log(`Collected ${collected.size} items`));
*/
@@ -296,6 +297,12 @@ class Message extends Base {
* @param {CollectorFilter} filter The filter function to use
* @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector
* @returns {Promise>}
+ * @example
+ * // Create a reaction collector
+ * const filter = (reaction, user) => reaction.emoji.name === 'π' && user.id === 'someID'
+ * message.awaitReactions(filter, { time: 15000 })
+ * .then(collected => console.log(`Collected ${collected.size} reactions`))
+ * .catch(console.error);
*/
awaitReactions(filter, options = {}) {
return new Promise((resolve, reject) => {
@@ -377,10 +384,10 @@ class Message extends Base {
}
if (!options.content) options.content = content;
- const { data, files } = await createMessage(this, options);
+ const { data } = await createMessage(this, options);
return this.client.api.channels[this.channel.id].messages[this.id]
- .patch({ data, files })
+ .patch({ data })
.then(d => {
const clone = this._clone();
clone._patch(d);
@@ -425,15 +432,6 @@ class Message extends Base {
}).reaction);
}
- /**
- * Removes all reactions from a message.
- * @returns {Promise}
- */
- clearReactions() {
- return this.client.api.channels(this.channel.id).messages(this.id).reactions.delete()
- .then(() => this);
- }
-
/**
* Deletes the message.
* @param {Object} [options] Options
diff --git a/src/structures/MessageCollector.js b/src/structures/MessageCollector.js
index a9260382c..d034a96db 100644
--- a/src/structures/MessageCollector.js
+++ b/src/structures/MessageCollector.js
@@ -51,24 +51,31 @@ class MessageCollector extends Collector {
/**
* Handles a message for possible collection.
* @param {Message} message The message that could be collected
- * @returns {?{key: Snowflake, value: Message}}
+ * @returns {?Snowflake}
* @private
*/
collect(message) {
+ /**
+ * Emitted whenever a message is collected.
+ * @event MessageCollector#collect
+ * @param {Message} message The message that was collected
+ */
if (message.channel.id !== this.channel.id) return null;
this.received++;
- return {
- key: message.id,
- value: message,
- };
+ return message.id;
}
/**
* Handles a message for possible disposal.
- * @param {Message} message The message that could be disposed
- * @returns {?string}
+ * @param {Message} message The message that could be disposed of
+ * @returns {?Snowflake}
*/
dispose(message) {
+ /**
+ * Emitted whenever a message is disposed of.
+ * @event MessageCollector#dispose
+ * @param {Message} message The message that was disposed of
+ */
return message.channel.id === this.channel.id ? message.id : null;
}
diff --git a/src/structures/MessageEmbed.js b/src/structures/MessageEmbed.js
index b7f430b75..297b19fff 100644
--- a/src/structures/MessageEmbed.js
+++ b/src/structures/MessageEmbed.js
@@ -67,8 +67,8 @@ class MessageEmbed {
this.thumbnail = data.thumbnail ? {
url: data.thumbnail.url,
proxyURL: data.thumbnail.proxy_url,
- height: data.height,
- width: data.width,
+ height: data.thumbnail.height,
+ width: data.thumbnail.width,
} : null;
/**
@@ -82,8 +82,8 @@ class MessageEmbed {
this.image = data.image ? {
url: data.image.url,
proxyURL: data.image.proxy_url,
- height: data.height,
- width: data.width,
+ height: data.image.height,
+ width: data.image.width,
} : null;
/**
@@ -175,9 +175,9 @@ class MessageEmbed {
addField(name, value, inline = false) {
if (this.fields.length >= 25) throw new RangeError('EMBED_FIELD_COUNT');
name = Util.resolveString(name);
- if (!String(name) || name.length > 256) throw new RangeError('EMBED_FIELD_NAME');
+ if (!String(name)) throw new RangeError('EMBED_FIELD_NAME');
value = Util.resolveString(value);
- if (!String(value) || value.length > 1024) throw new RangeError('EMBED_FIELD_VALUE');
+ if (!String(value)) throw new RangeError('EMBED_FIELD_VALUE');
this.fields.push({ name, value, inline });
return this;
}
@@ -193,7 +193,7 @@ class MessageEmbed {
/**
* 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.
+ * setting an embed image or author/footer icons. Multiple files can be attached.
* @param {Array} files Files to attach
* @returns {MessageEmbed}
*/
@@ -235,7 +235,6 @@ class MessageEmbed {
*/
setDescription(description) {
description = Util.resolveString(description);
- if (description.length > 2048) throw new RangeError('EMBED_DESCRIPTION');
this.description = description;
return this;
}
@@ -248,7 +247,6 @@ class MessageEmbed {
*/
setFooter(text, iconURL) {
text = Util.resolveString(text);
- if (text.length > 2048) throw new RangeError('EMBED_FOOTER_TEXT');
this.footer = { text, iconURL };
return this;
}
@@ -290,7 +288,6 @@ class MessageEmbed {
*/
setTitle(title) {
title = Util.resolveString(title);
- if (title.length > 256) throw new RangeError('EMBED_TITLE');
this.title = title;
return this;
}
diff --git a/src/structures/MessageMentions.js b/src/structures/MessageMentions.js
index ee4a0e69d..102e63aaa 100644
--- a/src/structures/MessageMentions.js
+++ b/src/structures/MessageMentions.js
@@ -22,7 +22,7 @@ class MessageMentions {
} else {
this.users = new Collection();
for (const mention of users) {
- let user = message.client.users.create(mention);
+ let user = message.client.users.add(mention);
this.users.set(user.id, user);
}
}
diff --git a/src/structures/MessageReaction.js b/src/structures/MessageReaction.js
index 28a320a63..660967f0b 100644
--- a/src/structures/MessageReaction.js
+++ b/src/structures/MessageReaction.js
@@ -1,7 +1,6 @@
-const Emoji = require('./Emoji');
+const GuildEmoji = require('./GuildEmoji');
const ReactionEmoji = require('./ReactionEmoji');
const ReactionUserStore = require('../stores/ReactionUserStore');
-const { Error } = require('../errors');
/**
* Represents a reaction to a message.
@@ -32,18 +31,18 @@ class MessageReaction {
*/
this.users = new ReactionUserStore(client, undefined, this);
- this._emoji = new ReactionEmoji(this, data.emoji.name, data.emoji.id);
+ this._emoji = new ReactionEmoji(this, data.emoji);
}
/**
- * The emoji of this reaction, either an Emoji object for known custom emojis, or a ReactionEmoji
+ * The emoji of this reaction, either an GuildEmoji object for known custom emojis, or a ReactionEmoji
* object which has fewer properties. Whatever the prototype of the emoji, it will still have
* `name`, `id`, `identifier` and `toString()`
- * @type {Emoji|ReactionEmoji}
+ * @type {GuildEmoji|ReactionEmoji}
* @readonly
*/
get emoji() {
- if (this._emoji instanceof Emoji) return this._emoji;
+ if (this._emoji instanceof GuildEmoji) return this._emoji;
// Check to see if the emoji has become known to the client
if (this._emoji.id) {
const emojis = this.message.client.emojis;
@@ -56,27 +55,6 @@ class MessageReaction {
return this._emoji;
}
- /**
- * Removes a user from this reaction.
- * @param {UserResolvable} [user=this.message.client.user] The user to remove the reaction of
- * @returns {Promise}
- */
- remove(user = this.message.client.user) {
- const userID = this.message.client.users.resolveID(user);
- if (!userID) return Promise.reject(new Error('REACTION_RESOLVE_USER'));
- return this.message.client.api.channels[this.message.channel.id].messages[this.message.id]
- .reactions[this.emoji.identifier][userID === this.message.client.user.id ? '@me' : userID]
- .delete()
- .then(() =>
- this.message.client.actions.MessageReactionRemove.handle({
- user_id: userID,
- message_id: this.message.id,
- emoji: this.emoji,
- channel_id: this.message.channel.id,
- }).reaction
- );
- }
-
_add(user) {
if (!this.users.has(user.id)) {
this.users.set(user.id, user);
diff --git a/src/structures/Presence.js b/src/structures/Presence.js
index 535c7d11d..e4359539d 100644
--- a/src/structures/Presence.js
+++ b/src/structures/Presence.js
@@ -1,4 +1,11 @@
-const { ActivityTypes } = require('../util/Constants');
+const { ActivityTypes, ActivityFlags } = require('../util/Constants');
+
+/**
+ * Activity sent in a message.
+ * @typedef {Object} MessageActivity
+ * @property {string} [partyID] Id of the party represented in activity
+ * @property {number} [type] Type of activity sent
+ */
/**
* Represents a user's presence.
@@ -118,6 +125,17 @@ class Activity {
* @type {?RichPresenceAssets}
*/
this.assets = data.assets ? new RichPresenceAssets(this, data.assets) : null;
+
+ this.syncID = data.sync_id;
+ this._flags = data.flags;
+ }
+
+ get flags() {
+ const flags = [];
+ for (const [name, flag] of Object.entries(ActivityFlags)) {
+ if ((this._flags & flag) === flag) flags.push(name);
+ }
+ return flags;
}
/**
@@ -134,6 +152,14 @@ class Activity {
);
}
+ /**
+ * When concatenated with a string, this automatically returns the activities's name instead of the Activity object.
+ * @returns {string}
+ */
+ toString() {
+ return this.name;
+ }
+
_clone() {
return Object.assign(Object.create(this), this);
}
@@ -193,6 +219,9 @@ class RichPresenceAssets {
*/
largeImageURL({ format, size } = {}) {
if (!this.largeImage) return null;
+ if (/^spotify:/.test(this.largeImage)) {
+ return `https://i.scdn.co/image/${this.largeImage.slice(8)}`;
+ }
return this.activity.presence.client.rest.cdn
.AppAsset(this.activity.applicationID, this.largeImage, { format, size });
}
diff --git a/src/structures/ReactionCollector.js b/src/structures/ReactionCollector.js
index 60bdabc48..27582893a 100644
--- a/src/structures/ReactionCollector.js
+++ b/src/structures/ReactionCollector.js
@@ -52,12 +52,12 @@ class ReactionCollector extends Collector {
this.client.removeListener(Events.MESSAGE_REACTION_REMOVE_ALL, this.empty);
});
- this.on('collect', (collected, reaction, user) => {
+ this.on('collect', (reaction, user) => {
this.total++;
this.users.set(user.id, user);
});
- this.on('dispose', (disposed, reaction, user) => {
+ this.on('remove', (reaction, user) => {
this.total--;
if (!this.collected.some(r => r.users.has(user.id))) this.users.delete(user.id);
});
@@ -66,23 +66,33 @@ class ReactionCollector extends Collector {
/**
* Handles an incoming reaction for possible collection.
* @param {MessageReaction} reaction The reaction to possibly collect
- * @returns {?{key: Snowflake, value: MessageReaction}}
+ * @returns {?Snowflake|string}
* @private
*/
collect(reaction) {
+ /**
+ * Emitted whenever a reaction is collected.
+ * @event ReactionCollector#collect
+ * @param {MessageReaction} reaction The reaction that was collected
+ * @param {User} user The user that added the reaction
+ */
if (reaction.message.id !== this.message.id) return null;
- return {
- key: ReactionCollector.key(reaction),
- value: reaction,
- };
+ return ReactionCollector.key(reaction);
}
/**
* Handles a reaction deletion for possible disposal.
- * @param {MessageReaction} reaction The reaction to possibly dispose
+ * @param {MessageReaction} reaction The reaction to possibly dispose of
+ * @param {User} user The user that removed the reaction
* @returns {?Snowflake|string}
*/
- dispose(reaction) {
+ dispose(reaction, user) {
+ /**
+ * Emitted whenever a reaction is disposed of.
+ * @event ReactionCollector#dispose
+ * @param {MessageReaction} reaction The reaction that was disposed of
+ * @param {User} user The user that removed the reaction
+ */
if (reaction.message.id !== this.message.id) return null;
/**
@@ -91,8 +101,11 @@ class ReactionCollector extends Collector {
* is removed.
* @event ReactionCollector#remove
* @param {MessageReaction} reaction The reaction that was removed
+ * @param {User} user The user that removed the reaction
*/
- if (this.collected.has(reaction)) this.emit('remove', reaction);
+ if (this.collected.has(ReactionCollector.key(reaction))) {
+ this.emit('remove', reaction, user);
+ }
return reaction.count ? null : ReactionCollector.key(reaction);
}
diff --git a/src/structures/ReactionEmoji.js b/src/structures/ReactionEmoji.js
index 94ea38930..9bb23c120 100644
--- a/src/structures/ReactionEmoji.js
+++ b/src/structures/ReactionEmoji.js
@@ -1,49 +1,19 @@
+const Emoji = require('./Emoji');
+
/**
* Represents a limited emoji set used for both custom and unicode emojis. Custom emojis
* will use this class opposed to the Emoji class when the client doesn't know enough
* information about them.
+ * @extends {Emoji}
*/
-class ReactionEmoji {
- constructor(reaction, name, id) {
+class ReactionEmoji extends Emoji {
+ constructor(reaction, emoji) {
+ super(reaction.message.client, emoji);
/**
* The message reaction this emoji refers to
* @type {MessageReaction}
*/
this.reaction = reaction;
-
- /**
- * The name of this reaction emoji
- * @type {string}
- */
- this.name = name;
-
- /**
- * The ID of this reaction emoji
- * @type {?Snowflake}
- */
- this.id = id;
- }
-
- /**
- * The identifier of this emoji, used for message reactions
- * @type {string}
- * @readonly
- */
- get identifier() {
- if (this.id) return `${this.name}:${this.id}`;
- return encodeURIComponent(this.name);
- }
-
- /**
- * When concatenated with a string, this automatically returns the text required to form a graphical emoji on Discord
- * instead of the ReactionEmoji object.
- * @returns {string}
- * @example
- * // Send the emoji used in a reaction to the channel the reaction is part of
- * reaction.message.channel.send(`The emoji used was: ${reaction.emoji}`);
- */
- toString() {
- return this.id ? `<:${this.name}:${this.id}>` : this.name;
}
}
diff --git a/src/structures/Role.js b/src/structures/Role.js
index 39c28e11b..68a3c3f03 100644
--- a/src/structures/Role.js
+++ b/src/structures/Role.js
@@ -2,7 +2,7 @@ const Snowflake = require('../util/Snowflake');
const Permissions = require('../util/Permissions');
const Util = require('../util/Util');
const Base = require('./Base');
-const { TypeError } = require('../errors');
+const { Error, TypeError } = require('../errors');
/**
* Represents a role on Discord.
@@ -95,9 +95,7 @@ class Role extends Base {
* @readonly
*/
get hexColor() {
- let col = this.color.toString(16);
- while (col.length < 6) col = `0${col}`;
- return `#${col}`;
+ return `#${this.color.toString(16).padStart(6, '0')}`;
}
/**
@@ -118,7 +116,7 @@ class Role extends Base {
if (this.managed) return false;
const clientMember = this.guild.member(this.client.user);
if (!clientMember.permissions.has(Permissions.FLAGS.MANAGE_ROLES)) return false;
- return clientMember.highestRole.comparePositionTo(this) > 0;
+ return clientMember.roles.highest.comparePositionTo(this) > 0;
}
/**
@@ -195,6 +193,18 @@ class Role extends Base {
});
}
+ /**
+ * Returns `channel.permissionsFor(role)`. Returns permissions for a role in a guild channel,
+ * taking into account permission overwrites.
+ * @param {ChannelResolvable} channel The guild channel to use as context
+ * @returns {?Permissions}
+ */
+ permissionsIn(channel) {
+ channel = this.guild.channels.resolve(channel);
+ if (!channel) throw new Error('GUILD_CHANNEL_RESOLVE');
+ return channel.rolePermissions(this);
+ }
+
/**
* Sets a new name for the role.
* @param {string} name The new name of the role
diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js
index 5679f841a..2c512dcee 100644
--- a/src/structures/TextChannel.js
+++ b/src/structures/TextChannel.js
@@ -35,7 +35,7 @@ class TextChannel extends GuildChannel {
this.lastMessageID = data.last_message_id;
- if (data.messages) for (const message of data.messages) this.messages.create(message);
+ if (data.messages) for (const message of data.messages) this.messages.add(message);
}
/**
diff --git a/src/structures/VoiceChannel.js b/src/structures/VoiceChannel.js
index 2fa37d233..05b840444 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 * 0.001;
+ this.bitrate = data.bitrate;
/**
* The maximum amount of users allowed in this channel - 0 means unlimited.
@@ -76,18 +76,17 @@ class VoiceChannel extends GuildChannel {
}
/**
- * Sets the bitrate of the channel (in kbps).
+ * Sets the bitrate of the channel.
* @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(48)
- * .then(vc => console.log(`Set bitrate to ${vc.bitrate}kbps for ${vc.name}`))
+ * voiceChannel.setBitrate(48000)
+ * .then(vc => console.log(`Set bitrate to ${vc.bitrate}bps for ${vc.name}`))
* .catch(console.error);
*/
setBitrate(bitrate, reason) {
- bitrate *= 1000;
return this.edit({ bitrate }, reason);
}
diff --git a/src/structures/Webhook.js b/src/structures/Webhook.js
index 7ee44745e..ba56ebdd3 100644
--- a/src/structures/Webhook.js
+++ b/src/structures/Webhook.js
@@ -124,7 +124,7 @@ class Webhook {
auth: false,
}).then(d => {
if (!this.client.channels) return d;
- return this.client.channels.get(d.channel_id).messages.create(d, false);
+ return this.client.channels.get(d.channel_id).messages.add(d, false);
});
}
@@ -152,7 +152,7 @@ class Webhook {
data: body,
}).then(data => {
if (!this.client.channels) return data;
- return this.client.channels.get(data.channel_id).messages.create(data, false);
+ return this.client.channels.get(data.channel_id).messages.add(data, false);
});
}
diff --git a/src/structures/interfaces/Collector.js b/src/structures/interfaces/Collector.js
index 2c4fd158c..0578dadfd 100644
--- a/src/structures/interfaces/Collector.js
+++ b/src/structures/interfaces/Collector.js
@@ -76,17 +76,17 @@ class Collector extends EventEmitter {
*/
handleCollect(...args) {
const collect = this.collect(...args);
- if (!collect || !this.filter(...args, this.collected)) return;
- this.collected.set(collect.key, collect.value);
+ if (collect && this.filter(...args, this.collected)) {
+ this.collected.set(collect, args[0]);
- /**
- * Emitted whenever an element is collected.
- * @event Collector#collect
- * @param {*} element The element that got collected
- * @param {...*} args The arguments emitted by the listener
- */
- this.emit('collect', collect.value, ...args);
+ /**
+ * Emitted whenever an element is collected.
+ * @event Collector#collect
+ * @param {...*} args The arguments emitted by the listener
+ */
+ this.emit('collect', ...args);
+ }
this.checkEnd();
}
@@ -100,17 +100,14 @@ class Collector extends EventEmitter {
const dispose = this.dispose(...args);
if (!dispose || !this.filter(...args) || !this.collected.has(dispose)) return;
-
- const value = this.collected.get(dispose);
this.collected.delete(dispose);
/**
- * Emitted whenever an element has been disposed.
+ * Emitted whenever an element is disposed of.
* @event Collector#dispose
- * @param {*} element The element that was disposed
* @param {...*} args The arguments emitted by the listener
*/
- this.emit('dispose', value, ...args);
+ this.emit('dispose', ...args);
this.checkEnd();
}
diff --git a/src/structures/interfaces/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js
index 39aac0937..701d56036 100644
--- a/src/structures/interfaces/TextBasedChannel.js
+++ b/src/structures/interfaces/TextBasedChannel.js
@@ -195,10 +195,8 @@ class TextBasedChannel {
* @returns {MessageCollector}
* @example
* // Create a message collector
- * const collector = channel.createMessageCollector(
- * m => m.content.includes('discord'),
- * { time: 15000 }
- * );
+ * const filter = m => m.content.includes('discord');
+ * const collector = channel.createMessageCollector(filter, { time: 15000 });
* collector.on('collect', m => console.log(`Collected ${m.content}`));
* collector.on('end', collected => console.log(`Collected ${collected.size} items`));
*/
@@ -246,6 +244,11 @@ class TextBasedChannel {
* Messages or number of messages to delete
* @param {boolean} [filterOld=false] Filter messages to remove those which are older than two weeks automatically
* @returns {Promise>} Deleted messages
+ * @example
+ * // Bulk delete messages
+ * channel.bulkDelete(5)
+ * .then(messages => console.log(`Bulk deleted ${messages.size} messages`))
+ * .catch(console.error);
*/
async bulkDelete(messages, filterOld = false) {
if (messages instanceof Array || messages instanceof Collection) {
diff --git a/src/structures/shared/CreateMessage.js b/src/structures/shared/CreateMessage.js
index 5abf0799e..55d779cf5 100644
--- a/src/structures/shared/CreateMessage.js
+++ b/src/structures/shared/CreateMessage.js
@@ -4,6 +4,7 @@ const MessageEmbed = require('../MessageEmbed');
const MessageAttachment = require('../MessageAttachment');
const { browser } = require('../../util/Constants');
const Util = require('../../util/Util');
+const { RangeError } = require('../../errors');
// eslint-disable-next-line complexity
module.exports = async function createMessage(channel, options) {
@@ -19,18 +20,31 @@ module.exports = async function createMessage(channel, options) {
if (isNaN(options.nonce) || options.nonce < 0) throw new RangeError('MESSAGE_NONCE_TYPE');
}
+ let { content } = options;
if (options instanceof MessageEmbed) options = webhook ? { embeds: [options] } : { embed: options };
if (options instanceof MessageAttachment) options = { files: [options.file] };
+ if (content instanceof Array || options instanceof Array) {
+ const which = content instanceof Array ? content : options;
+ const attachments = which.filter(item => item instanceof MessageAttachment);
+ const embeds = which.filter(item => item instanceof MessageEmbed);
+ if (attachments.length) options = { files: attachments };
+ if (embeds.length) options = { embeds };
+ if ((embeds.length || attachments.length) && content instanceof Array) {
+ content = null;
+ options.content = '';
+ }
+ }
+
if (options.reply && !(channel instanceof User || channel instanceof GuildMember) && channel.type !== 'dm') {
const id = channel.client.users.resolveID(options.reply);
const mention = `<@${options.reply instanceof GuildMember && options.reply.nickname ? '!' : ''}${id}>`;
if (options.split) options.split.prepend = `${mention}, ${options.split.prepend || ''}`;
- options.content = `${mention}${typeof options.content !== 'undefined' ? `, ${options.content}` : ''}`;
+ content = `${mention}${typeof options.content !== 'undefined' ? `, ${options.content}` : ''}`;
}
- if (options.content) {
- options.content = Util.resolveString(options.content);
+ if (content) {
+ options.content = Util.resolveString(content);
if (options.split && typeof options.split !== 'object') options.split = {};
// Wrap everything in a code block
if (typeof options.code !== 'undefined' && (typeof options.code !== 'boolean' || options.code === true)) {
diff --git a/src/structures/shared/Search.js b/src/structures/shared/Search.js
index 3adca7fcc..db30b572c 100644
--- a/src/structures/shared/Search.js
+++ b/src/structures/shared/Search.js
@@ -90,7 +90,7 @@ module.exports = function search(target, options) {
let endpoint = target.client.api[target instanceof Channel ? 'channels' : 'guilds'](target.id).messages().search;
return endpoint.get({ query: options }).then(body => {
const results = body.messages.map(x =>
- x.map(m => target.client.channels.get(m.channel_id).messages.create(m, false))
+ x.map(m => target.client.channels.get(m.channel_id).messages.add(m, false))
);
return {
total: body.total_results,
diff --git a/src/util/Collection.js b/src/util/Collection.js
index 1f3fc6307..acddf0518 100644
--- a/src/util/Collection.js
+++ b/src/util/Collection.js
@@ -192,12 +192,12 @@ class Collection extends Map {
for (const item of this.values()) {
if (item[propOrFn] === value) return item;
}
- return null;
+ return undefined;
} else if (typeof propOrFn === 'function') {
for (const [key, val] of this) {
if (propOrFn(val, key, this)) return val;
}
- return null;
+ return undefined;
} else {
throw new Error('First argument must be a property string or a function.');
}
@@ -223,12 +223,12 @@ class Collection extends Map {
for (const [key, val] of this) {
if (val[propOrFn] === value) return key;
}
- return null;
+ return undefined;
} else if (typeof propOrFn === 'function') {
for (const [key, val] of this) {
if (propOrFn(val, key, this)) return key;
}
- return null;
+ return undefined;
} else {
throw new Error('First argument must be a property string or a function.');
}
diff --git a/src/util/Constants.js b/src/util/Constants.js
index efeeb29fa..b29a2dea8 100644
--- a/src/util/Constants.js
+++ b/src/util/Constants.js
@@ -26,6 +26,7 @@ const browser = exports.browser = typeof window !== 'undefined';
* corresponding websocket events
* @property {number} [restTimeOffset=500] Extra time in millseconds to wait before continuing to make REST
* requests (higher values will reduce rate-limiting errors on bad connections)
+ * @property {PresenceData} [presence] Presence data to use upon login
* @property {WSEventType[]} [disabledEvents] An array of disabled websocket events. Events in this array will not be
* processed, potentially resulting in performance improvements for larger bots. Only disable events you are
* 100% certain you don't need, as many are important, but not obviously so. The safest one to disable with the
@@ -47,6 +48,7 @@ exports.DefaultOptions = {
restWsBridgeTimeout: 5000,
disabledEvents: [],
restTimeOffset: 500,
+ presence: {},
/**
* WebSocket options (these are left as snake_case to match the API)
@@ -110,7 +112,7 @@ function makeImageUrl(root, { format = 'webp', size } = {}) {
exports.Endpoints = {
CDN(root) {
return {
- Emoji: emojiID => `${root}/emojis/${emojiID}.png`,
+ Emoji: (emojiID, format = 'png') => `${root}/emojis/${emojiID}.${format}`,
Asset: name => `${root}/assets/${name}`,
DefaultAvatar: number => `${root}/embed/avatars/${number}.png`,
Avatar: (userID, hash, format = 'default', size) => {
@@ -234,6 +236,8 @@ exports.Events = {
USER_GUILD_SETTINGS_UPDATE: 'clientUserGuildSettingsUpdate',
PRESENCE_UPDATE: 'presenceUpdate',
VOICE_STATE_UPDATE: 'voiceStateUpdate',
+ VOICE_BROADCAST_SUBSCRIBE: 'subscribe',
+ VOICE_BROADCAST_UNSUBSCRIBE: 'unsubscribe',
TYPING_START: 'typingStart',
TYPING_STOP: 'typingStop',
DISCONNECT: 'disconnect',
@@ -360,6 +364,15 @@ exports.ActivityTypes = [
'WATCHING',
];
+exports.ActivityFlags = {
+ INSTANCE: 1 << 0,
+ JOIN: 1 << 1,
+ SPECTATE: 1 << 2,
+ JOIN_REQUEST: 1 << 3,
+ SYNC: 1 << 4,
+ PLAY: 1 << 5,
+};
+
exports.ExplicitContentFilterTypes = [
'DISABLED',
'NON_FRIENDS',
@@ -657,7 +670,7 @@ exports.Colors = {
* * CANNOT_PIN_MESSAGE_IN_OTHER_CHANNEL
* * CANNOT_EXECUTE_ON_SYSTEM_MESSAGE
* * BULK_DELETE_MESSAGE_TOO_OLD
- * * INVITE_ACCEPTED_TO_GUILD_NOT_CONTANING_BOT
+ * * INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT
* * REACTION_BLOCKED
* @typedef {string} APIError
*/
@@ -703,7 +716,7 @@ exports.APIErrors = {
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,
+ INVITE_ACCEPTED_TO_GUILD_NOT_CONTAINING_BOT: 50036,
REACTION_BLOCKED: 90001,
};
diff --git a/src/util/DataResolver.js b/src/util/DataResolver.js
index ff2dc75b7..91a698893 100644
--- a/src/util/DataResolver.js
+++ b/src/util/DataResolver.js
@@ -2,7 +2,7 @@ const path = require('path');
const fs = require('fs');
const snekfetch = require('snekfetch');
const Util = require('../util/Util');
-const { Error, TypeError } = require('../errors');
+const { Error: DiscordError, TypeError } = require('../errors');
const { browser } = require('../util/Constants');
/**
@@ -99,7 +99,7 @@ class DataResolver {
const file = browser ? resource : path.resolve(resource);
fs.stat(file, (err, stats) => {
if (err) return reject(err);
- if (!stats || !stats.isFile()) return reject(new Error('FILE_NOT_FOUND', file));
+ if (!stats || !stats.isFile()) return reject(new DiscordError('FILE_NOT_FOUND', file));
fs.readFile(file, (err2, data) => {
if (err2) reject(err2); else resolve(data);
});
diff --git a/src/util/Permissions.js b/src/util/Permissions.js
index 7ccd9009c..e9ef9d1c5 100644
--- a/src/util/Permissions.js
+++ b/src/util/Permissions.js
@@ -7,19 +7,19 @@ const { RangeError } = require('../errors');
*/
class Permissions {
/**
- * @param {number|PermissionResolvable[]} permissions Permissions or bitfield to read from
+ * @param {PermissionResolvable} permissions Permission(s) to read from
*/
constructor(permissions) {
/**
* Bitfield of the packed permissions
* @type {number}
*/
- this.bitfield = typeof permissions === 'number' ? permissions : this.constructor.resolve(permissions);
+ this.bitfield = this.constructor.resolve(permissions);
}
/**
* Checks whether the bitfield has a permission, or multiple permissions.
- * @param {PermissionResolvable|PermissionResolvable[]} permission Permission(s) to check for
+ * @param {PermissionResolvable} permission Permission(s) to check for
* @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override
* @returns {boolean}
*/
@@ -32,11 +32,12 @@ class Permissions {
/**
* Gets all given permissions that are missing from the bitfield.
- * @param {PermissionResolvable[]} permissions Permissions to check for
+ * @param {PermissionResolvable} permissions Permission(s) to check for
* @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override
- * @returns {PermissionResolvable[]}
+ * @returns {string[]}
*/
missing(permissions, checkAdmin = true) {
+ if (!(permissions instanceof Array)) permissions = new this.constructor(permissions).toArray(false);
return permissions.filter(p => !this.has(p, checkAdmin));
}
@@ -92,17 +93,32 @@ class Permissions {
return serialized;
}
+ /**
+ * Gets an {@link Array} of permission names (such as `VIEW_CHANNEL`) based on the permissions available.
+ * @param {boolean} [checkAdmin=true] Whether to allow the administrator permission to override
+ * @returns {string[]}
+ */
+ toArray(checkAdmin = true) {
+ return Object.keys(this.constructor.FLAGS).filter(perm => this.has(perm, checkAdmin));
+ }
+
+ *[Symbol.iterator]() {
+ const keys = this.toArray();
+ while (keys.length) yield keys.shift();
+ }
+
/**
* Data that can be resolved to give a permission number. This can be:
* * A string (see {@link Permissions.FLAGS})
* * A permission number
* * An instance of Permissions
- * @typedef {string|number|Permissions} PermissionResolvable
+ * * An Array of PermissionResolvable
+ * @typedef {string|number|Permissions|PermissionResolvable[]} PermissionResolvable
*/
/**
* Resolves permissions to their numeric form.
- * @param {PermissionResolvable|PermissionResolvable[]} permission - Permission(s) to resolve
+ * @param {PermissionResolvable} permission - Permission(s) to resolve
* @returns {number}
*/
static resolve(permission) {
diff --git a/src/util/Structures.js b/src/util/Structures.js
index a1cb7e156..e7f615c79 100644
--- a/src/util/Structures.js
+++ b/src/util/Structures.js
@@ -61,7 +61,7 @@ class Structures {
}
const structures = {
- Emoji: require('../structures/Emoji'),
+ GuildEmoji: require('../structures/GuildEmoji'),
DMChannel: require('../structures/DMChannel'),
GroupDMChannel: require('../structures/GroupDMChannel'),
TextChannel: require('../structures/TextChannel'),
diff --git a/src/util/Util.js b/src/util/Util.js
index d92dc94b7..d7988599d 100644
--- a/src/util/Util.js
+++ b/src/util/Util.js
@@ -18,21 +18,20 @@ class Util {
* @param {SplitOptions} [options] Options controlling the behaviour of the split
* @returns {string|string[]}
*/
- static splitMessage(text, { maxLength = 1950, char = '\n', prepend = '', append = '' } = {}) {
+ static splitMessage(text, { maxLength = 2000, char = '\n', prepend = '', append = '' } = {}) {
if (text.length <= maxLength) return text;
const splitText = text.split(char);
if (splitText.length === 1) throw new RangeError('SPLIT_MAX_LEN');
- const messages = [''];
- let msg = 0;
- for (let i = 0; i < splitText.length; i++) {
- if (messages[msg].length + splitText[i].length + 1 > maxLength) {
- messages[msg] += append;
- messages.push(prepend);
- msg++;
+ const messages = [];
+ let msg = '';
+ for (const chunk of splitText) {
+ if (msg && (msg + char + chunk + append).length > maxLength) {
+ messages.push(msg + append);
+ msg = prepend;
}
- messages[msg] += (messages[msg].length > 0 && messages[msg] !== prepend ? char : '') + splitText[i];
+ msg += (msg && msg !== prepend ? char : '') + chunk;
}
- return messages.filter(m => m);
+ return messages.concat(msg).filter(m => m);
}
/**
@@ -70,19 +69,17 @@ 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 Discord custom emoji (`<:name:id>` or ``)
* @param {string} text Emoji string to parse
- * @returns {Object} Object with `name` and `id` properties
+ * @returns {Object} Object with `animated`, `name`, and `id` properties
* @private
*/
static parseEmoji(text) {
if (text.includes('%')) text = decodeURIComponent(text);
- if (text.includes(':')) {
- const [name, id] = text.split(':');
- return { name, id };
- } else {
- return { name: text, id: null };
- }
+ if (!text.includes(':')) return { animated: false, name: text, id: null };
+ const m = text.match(/(a)?:?(\w{2,32}):(\d{17,19})>?/);
+ if (!m) return null;
+ return { animated: Boolean(m[1]), name: m[2], id: m[3] };
}
/**
@@ -127,7 +124,7 @@ class Util {
if (!has(given, key) || given[key] === undefined) {
given[key] = def[key];
} else if (given[key] === Object(given[key])) {
- given[key] = this.mergeDefault(def[key], given[key]);
+ given[key] = Util.mergeDefault(def[key], given[key]);
}
}
@@ -141,7 +138,7 @@ class Util {
* @private
*/
static convertToBuffer(ab) {
- if (typeof ab === 'string') ab = this.str2ab(ab);
+ if (typeof ab === 'string') ab = Util.str2ab(ab);
return Buffer.from(ab);
}
@@ -181,11 +178,11 @@ class Util {
* @private
*/
static makePlainError(err) {
- const obj = {};
- obj.name = err.name;
- obj.message = err.message;
- obj.stack = err.stack;
- return obj;
+ return {
+ name: err.name,
+ message: err.message,
+ stack: err.stack,
+ };
}
/**
@@ -265,6 +262,7 @@ class Util {
static resolveColor(color) {
if (typeof color === 'string') {
if (color === 'RANDOM') return Math.floor(Math.random() * (0xFFFFFF + 1));
+ if (color === 'DEFAULT') return 0;
color = Colors[color] || parseInt(color.replace('#', ''), 16);
} else if (color instanceof Array) {
color = (color[0] << 16) + (color[1] << 8) + color[2];
@@ -315,8 +313,8 @@ class Util {
* @private
*/
static basename(path, ext) {
- let f = splitPathRe.exec(path).slice(1)[2];
- if (ext && f.substr(-1 * ext.length) === ext) f = f.substr(0, f.length - ext.length);
+ let f = splitPathRe.exec(path)[3];
+ if (ext && f.endsWith(ext)) f = f.slice(0, -ext.length);
return f;
}
diff --git a/test/voice.js b/test/voice.js
index 0b36636a3..07cc3a4bf 100644
--- a/test/voice.js
+++ b/test/voice.js
@@ -3,51 +3,50 @@
const Discord = require('../');
const ytdl = require('ytdl-core');
+const prism = require('prism-media');
+const fs = require('fs');
const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' });
-const auth = require('./auth.json');
+const auth = require('./auth.js');
client.login(auth.token).then(() => console.log('logged')).catch(console.error);
const connections = new Map();
-let broadcast;
+var d, b;
+
+client.on('debug', console.log);
+client.on('error', console.log);
+
+async function wait(time = 1000) {
+ return new Promise(resolve => {
+ setTimeout(resolve, time);
+ });
+}
+
+var count = 0;
+
+process.on('unhandledRejection', console.log);
client.on('message', m => {
if (!m.guild) return;
+ if (m.author.id !== '66564597481480192') 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 => {
+ const receiver = conn.createReceiver();
+ receiver.createStream(m.author, true).on('data', b => console.log(b.toString()));
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!');
+ // conn.playOpusStream(fs.createReadStream('C:/users/amish/downloads/z.ogg').pipe(new prism.OggOpusDemuxer()));
+ d = conn.play(ytdl('https://www.youtube.com/watch?v=_XXOSf0s2nk', { filter: 'audioonly' }, { passes: 3 }));
});
} 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(' '));
@@ -58,21 +57,3 @@ client.on('message', m => {
}
}
});
-
-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 0b5b13f4a..604441490 160000
--- a/typings
+++ b/typings
@@ -1 +1 @@
-Subproject commit 0b5b13f4a521cba0fc42aa0f9b2c4a1abca2de3d
+Subproject commit 60444149022d84dc2626cff1a91004c31d73f491