This commit is contained in:
Amish Shah
2016-10-26 20:58:34 +01:00
91 changed files with 3527 additions and 1226 deletions

6
.gitignore vendored
View File

@@ -35,4 +35,8 @@ build/Release
node_modules
test/auth.json
examples/auth.json
docs/_build
docs/_build
# Secret keys
docs/deploy/deploy_key
docs/deploy/deploy_key.pub

View File

@@ -5,3 +5,9 @@ cache:
directories:
- node_modules
install: npm install
script: bash ./docs/deploy/deploy.sh
env:
global:
- ENCRYPTION_LABEL: "af862fa96d3e"
- COMMIT_AUTHOR_EMAIL: "amishshah.2k@gmail.com"

View File

@@ -7,8 +7,8 @@ is a great boon to your coding process.
To get ready to work on the codebase, please do the following:
1. Fork & clone the repository
2. Run `npm install`, or `npm install --no-optional` if you're not working on voice
3. Code your heart out!
4. Run `npm test` to run ESLint
5. Run `npm run docs` to build any documentation changes
2. Run `npm install`
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)

View File

@@ -1,6 +1,6 @@
<p align="center">
<a href="https://hydrabolt.github.io/discord.js">
<img alt="discord.js" src="http://i.imgur.com/sPOLh9y.png" width="546"><br />
<img alt="discord.js" src="http://i.imgur.com/0af7LDs.png" width="546"><br />
</a>
</p>
@@ -16,12 +16,14 @@ discord.js is a powerful node.js module that allows you to interact with the [Di
## Installation
**Node.js 6.0.0 or newer is required.**
With voice support: `npm install --save discord.js --production`
Without voice support: `npm install --save discord.js --production --no-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`
If both audio packages are installed, discord.js will automatically choose node-opus.
By default, discord.js uses [opusscript](https://www.npmjs.com/package/opusscript) when playing audio over voice connections.
If you're looking to play over multiple voice connections, it might be better to install [node-opus](https://www.npmjs.com/package/node-opus).
discord.js will automatically prefer node-opus over opusscript.
The preferred audio engine is node-opus, as it performs significantly better than opusscript.
Using opusscript is only recommended for development on Windows, since getting node-opus to build there can be a bit of a challenge.
For production bots, using node-opus should be considered a necessity, especially if they're going to be running on multiple servers.
## Example Usage
```js
@@ -41,17 +43,24 @@ client.on('message', message => {
client.login('your token');
```
A bot template using discord.js can be generated using [generator-discordbot](https://www.npmjs.com/package/generator-discordbot).
## Links
* [Website](http://hydrabolt.github.io/discord.js/)
* [Discord.js Server](https://discord.gg/bRCvFy9)
* [Discord API Server](https://discord.gg/rV4BwdK)
* [Discord.js server](https://discord.gg/bRCvFy9)
* [Discord API server](https://discord.gg/rV4BwdK)
* [Documentation](http://hydrabolt.github.io/discord.js/#!/docs/tag/master)
* [Legacy Documentation](http://discordjs.readthedocs.io/en/8.1.0/docs_client.html)
* [Legacy (v8) documentation](http://discordjs.readthedocs.io/en/8.2.0/docs_client.html)
* [Examples](https://github.com/hydrabolt/discord.js/tree/master/docs/custom/examples)
* [GitHub](https://github.com/hydrabolt/discord.js)
* [NPM](https://www.npmjs.com/package/discord.js)
* [Examples](https://github.com/hydrabolt/discord.js/tree/master/docs/custom/examples)
* [Related Libraries](https://discordapi.com/unofficial/libs.html)
* [Related libraries](https://discordapi.com/unofficial/libs.html)
## Contact
Before reporting an issue, please read the [documentation](http://hydrabolt.github.io/discord.js/#!/docs/tag/master).
If you can't find help there, you can ask in the official [Discord.js Server](https://discord.gg/bRCvFy9).
## Contributing
Before creating an issue, please ensure that it hasn't already been reported/suggested, and double-check the
[documentation](http://hydrabolt.github.io/discord.js/#!/docs/tag/master).
See [the contributing guide](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
nudge in the right direction, please don't hesitate to join our official [Discord.js Server](https://discord.gg/bRCvFy9).

2
docs/README.md Normal file
View File

@@ -0,0 +1,2 @@
# discord.js docs
[View documentation here](http://hydrabolt.github.io/discord.js/#!/docs/)

View File

@@ -20,12 +20,14 @@ stable and performant than previous versions.
## Installation
**Node.js 6.0.0 or newer is required.**
With voice support: `npm install --save discord.js --production`
Without voice support: `npm install --save discord.js --production --no-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`
If both audio packages are installed, discord.js will automatically prefer node-opus.
By default, discord.js uses [opusscript](https://www.npmjs.com/package/opusscript) when playing audio over voice connections.
If you're looking to play over multiple voice connections, it might be better to install [node-opus](https://www.npmjs.com/package/node-opus).
discord.js will automatically prefer node-opus over opusscript.
The preferred audio engine is node-opus, as it performs significantly better than opusscript.
Using opusscript is only recommended for development on Windows, since getting node-opus to build there can be a bit of a challenge.
For production bots, using node-opus should be considered a necessity, especially if they're going to be running on multiple servers.
## Guides
* [LuckyEvie's general guide](https://eslachance.gitbooks.io/discord-js-bot-guide/content/)
@@ -33,15 +35,15 @@ discord.js will automatically prefer node-opus over opusscript.
## Links
* [Website](http://hydrabolt.github.io/discord.js/)
* [Discord.js Server](https://discord.gg/bRCvFy9)
* [Discord API Server](https://discord.gg/rV4BwdK)
* [Discord.js server](https://discord.gg/bRCvFy9)
* [Discord API server](https://discord.gg/rV4BwdK)
* [Documentation](http://hydrabolt.github.io/discord.js/#!/docs/tag/master)
* [Legacy Documentation](http://discordjs.readthedocs.io/en/8.1.0/docs_client.html)
* [Legacy (v8) documentation](http://discordjs.readthedocs.io/en/8.2.0/docs_client.html)
* [Examples](https://github.com/hydrabolt/discord.js/tree/master/docs/custom/examples)
* [GitHub](https://github.com/hydrabolt/discord.js)
* [NPM](https://www.npmjs.com/package/discord.js)
* [Examples](https://github.com/hydrabolt/discord.js/tree/master/docs/custom/examples)
* [Related Libraries](https://discordapi.com/unofficial/libs.html)
* [Related libraries](https://discordapi.com/unofficial/libs.html)
## Help
If you don't understand something in this documentation, you are experiencing problems, or you just need a gentle
If you don't understand something in the documentation, you are experiencing problems, or you just need a gentle
nudge in the right direction, please don't hesitate to join our official [Discord.js Server](https://discord.gg/bRCvFy9).

View File

@@ -0,0 +1,12 @@
/*
Send a message using a webhook
*/
// import the discord.js module
const Discord = require('discord.js');
// create a new webhook
const hook = new Discord.WebhookClient('webhook id', 'webhook token');
// send a message using the webhook
hook.sendMessage('I am now alive!');

10
docs/custom/webhook.js Normal file
View File

@@ -0,0 +1,10 @@
const fs = require('fs');
module.exports = {
category: 'Examples',
name: 'Webhooks',
data:
`\`\`\`js
${fs.readFileSync('./docs/custom/examples/webhook.js').toString('utf-8')}
\`\`\``,
};

68
docs/deploy/deploy.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Adapted from https://gist.github.com/domenic/ec8b0fc8ab45f39403dd.
set -e
function build {
node docs/generator/generator.js
}
# Ignore Travis checking PRs
if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
echo "deploy.sh: Ignoring PR build"
build
exit 0
fi
# Ignore travis checking other branches irrelevant to users
if [ "$TRAVIS_BRANCH" != "master" -a "$TRAVIS_BRANCH" != "indev" ]; then
echo "deploy.sh: Ignoring push to another branch than master/indev"
build
exit 0
fi
SOURCE=$TRAVIS_BRANCH
# Make sure tag pushes are handled
if [ -n "$TRAVIS_TAG" ]; then
echo "deploy.sh: This is a tag build, proceeding accordingly"
SOURCE=$TRAVIS_TAG
fi
REPO=`git config remote.origin.url`
SSH_REPO=${REPO/https:\/\/github.com\//git@github.com:}
SHA=`git rev-parse --verify HEAD`
TARGET_BRANCH="docs"
# Checkout the repo in the target branch so we can build docs and push to it
git clone $REPO out -b $TARGET_BRANCH
cd out
cd ..
# Build the docs
build
# Move the generated JSON file to the newly-checked-out repo, to be committed
# and pushed
mv docs/docs.json out/$SOURCE.json
# Commit and push
cd out
git config user.name "Travis CI"
git config user.email "$COMMIT_AUTHOR_EMAIL"
git add .
git commit -m "Docs build: ${SHA}"
ENCRYPTED_KEY_VAR="encrypted_${ENCRYPTION_LABEL}_key"
ENCRYPTED_IV_VAR="encrypted_${ENCRYPTION_LABEL}_iv"
ENCRYPTED_KEY=${!ENCRYPTED_KEY_VAR}
ENCRYPTED_IV=${!ENCRYPTED_IV_VAR}
openssl aes-256-cbc -K $ENCRYPTED_KEY -iv $ENCRYPTED_IV -in ../docs/deploy/deploy_key.enc -out deploy_key -d
chmod 600 deploy_key
eval `ssh-agent -s`
ssh-add deploy_key
# Now that we're all set up, we can push.
git push $SSH_REPO $TARGET_BRANCH

BIN
docs/deploy/deploy_key.enc Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{
"name": "discord.js",
"version": "9.3.1",
"version": "10.0.0",
"description": "A powerful library for interacting with the Discord API",
"main": "./src/index",
"scripts": {
@@ -28,7 +28,10 @@
"dependencies": {
"superagent": "^2.2.0",
"tweetnacl": "^0.14.3",
"ws": "^1.1.1",
"ws": "^1.1.1"
},
"peerDependencies": {
"node-opus": "^0.2.1",
"opusscript": "^0.0.1"
},
"devDependencies": {
@@ -36,9 +39,6 @@
"jsdoc-parse": "^1.2.0",
"eslint": "^3.4.0"
},
"optionalDependencies": {
"node-opus": "^0.2.1"
},
"engines": {
"node": ">=6.0.0"
}

View File

@@ -9,6 +9,8 @@ const ClientVoiceManager = require('./voice/ClientVoiceManager');
const WebSocketManager = require('./websocket/WebSocketManager');
const ActionsManager = require('./actions/ActionsManager');
const Collection = require('../util/Collection');
const Presence = require('../structures/Presence').Presence;
const ShardClientUtil = require('../sharding/ShardClientUtil');
/**
* The starting point for making a Discord Bot.
@@ -18,22 +20,19 @@ class Client extends EventEmitter {
/**
* @param {ClientOptions} [options] Options for the client
*/
constructor(options) {
constructor(options = {}) {
super();
// Obtain shard details from environment
if (!options.shardId && 'SHARD_ID' in process.env) options.shardId = Number(process.env.SHARD_ID);
if (!options.shardCount && 'SHARD_COUNT' in process.env) options.shardCount = Number(process.env.SHARD_COUNT);
/**
* The options the client was instantiated with
* @type {ClientOptions}
*/
this.options = mergeDefault(Constants.DefaultOptions, options);
if (!this.options.shard_id && 'SHARD_ID' in process.env) {
this.options.shard_id = process.env.SHARD_ID;
}
if (!this.options.shard_count && 'SHARD_COUNT' in process.env) {
this.options.shard_count = process.env.SHARD_COUNT;
}
this._validateOptions();
/**
* The REST manager of the client
@@ -84,6 +83,12 @@ class Client extends EventEmitter {
*/
this.voice = new ClientVoiceManager(this);
/**
* The shard helpers for the client (only if the process was spawned as a child, such as from a ShardingManager)
* @type {?ShardUtil}
*/
this.shard = process.send ? ShardClientUtil.singleton(this) : null;
/**
* A Collection of the Client's stored users
* @type {Collection<string, User>}
@@ -103,10 +108,21 @@ class Client extends EventEmitter {
this.channels = new Collection();
/**
* The authorization token for the logged in user/bot.
* @type {?string}
* A Collection of presences for friends of the logged in user.
* <warn>This is only present for user accounts, not bot accounts!</warn>
* @type {Collection<string, Presence>}
*/
this.token = null;
this.presences = new Collection();
if (!this.token && 'CLIENT_TOKEN' in process.env) {
/**
* The authorization token for the logged in user/bot.
* @type {?string}
*/
this.token = process.env.CLIENT_TOKEN;
} else {
this.token = null;
}
/**
* The email, if there is one, for the logged in Client
@@ -130,38 +146,20 @@ class Client extends EventEmitter {
* The date at which the Client was regarded as being in the `READY` state.
* @type {?Date}
*/
this.readyTime = null;
this.readyAt = null;
this._timeouts = new Set();
this._intervals = new Set();
if (this.options.message_sweep_interval > 0) {
this.setInterval(this.sweepMessages.bind(this), this.options.message_sweep_interval * 1000);
}
if (process.send) {
process.on('message', message => {
if (!message) return;
if (message._eval) {
try {
process.send({ _evalResult: eval(message._eval) });
} catch (err) {
process.send({ _evalError: err });
}
} else if (message._fetchProp) {
const props = message._fetchProp.split('.');
let value = this; // eslint-disable-line consistent-this
for (const prop of props) value = value[prop];
process.send({ _fetchProp: message._fetchProp, _fetchPropValue: value });
}
});
if (this.options.messageSweepInterval > 0) {
this.setInterval(this.sweepMessages.bind(this), this.options.messageSweepInterval * 1000);
}
}
/**
* The status for the logged in Client.
* @readonly
* @type {?number}
* @readonly
*/
get status() {
return this.ws.status;
@@ -169,17 +167,17 @@ class Client extends EventEmitter {
/**
* The uptime for the logged in Client.
* @readonly
* @type {?number}
* @readonly
*/
get uptime() {
return this.readyTime ? Date.now() - this.readyTime : null;
return this.readyAt ? Date.now() - this.readyAt : null;
}
/**
* Returns a Collection, mapping Guild ID to Voice Connections.
* @readonly
* @type {Collection<string, VoiceConnection>}
* @readonly
*/
get voiceConnections() {
return this.voice.connections;
@@ -192,10 +190,21 @@ class Client extends EventEmitter {
*/
get emojis() {
const emojis = new Collection();
this.guilds.map(g => g.emojis.map(e => emojis.set(e.id, e)));
for (const guild of this.guilds.values()) {
for (const emoji of guild.emojis.values()) emojis.set(emoji.id, emoji);
}
return emojis;
}
/**
* The timestamp that the client was last ready at
* @type {?number}
* @readonly
*/
get readyTimestamp() {
return this.readyAt ? this.readyAt.getTime() : null;
}
/**
* Logs the client in. If successful, resolves with the account's token. <warn>If you're making a bot, it's
* much better to use a bot account rather than a user account.
@@ -225,30 +234,26 @@ class Client extends EventEmitter {
* @returns {Promise}
*/
destroy() {
return new Promise((resolve, reject) => {
this.manager.destroy().then(() => {
for (const t of this._timeouts) clearTimeout(t);
for (const i of this._intervals) clearInterval(i);
this._timeouts = [];
this._intervals = [];
this.token = null;
this.email = null;
this.password = null;
resolve();
}).catch(reject);
});
for (const t of this._timeouts) clearTimeout(t);
for (const i of this._intervals) clearInterval(i);
this._timeouts = [];
this._intervals = [];
this.token = null;
this.email = null;
this.password = null;
return this.manager.destroy();
}
/**
* This shouldn't really be necessary to most developers as it is automatically invoked every 30 seconds, however
* if you wish to force a sync of Guild data, you can use this. Only applicable to user accounts.
* @param {Guild[]} [guilds=this.guilds.array()] An array of guilds to sync
* @param {Guild[]|Collection<string, Guild>} [guilds=this.guilds] An array or collection of guilds to sync
*/
syncGuilds(guilds = this.guilds.array()) {
syncGuilds(guilds = this.guilds) {
if (!this.user.bot) {
this.ws.send({
op: 12,
d: guilds.map(g => g.id),
d: guilds instanceof Collection ? guilds.keyArray() : guilds.map(g => g.id),
});
}
}
@@ -266,23 +271,33 @@ class Client extends EventEmitter {
/**
* Fetches an invite object from an invite code.
* @param {string} code the invite code.
* @param {InviteResolvable} invite An invite code or URL
* @returns {Promise<Invite>}
*/
fetchInvite(code) {
fetchInvite(invite) {
const code = this.resolver.resolveInviteCode(invite);
return this.rest.methods.getInvite(code);
}
/**
* Fetch a webhook by ID.
* @param {string} id ID of the webhook
* @returns {Promise<Webhook>}
*/
fetchWebhook(id) {
return this.rest.methods.getWebhook(id);
}
/**
* Sweeps all channels' messages and removes the ones older than the max message lifetime.
* If the message has been edited, the time of the edit is used rather than the time of the original message.
* @param {number} [lifetime=this.options.message_cache_lifetime] Messages that are older than this (in seconds)
* will be removed from the caches. The default is based on the client's `message_cache_lifetime` option.
* @param {number} [lifetime=this.options.messageCacheLifetime] Messages that are older than this (in seconds)
* will be removed from the caches. The default is based on the client's `messageCacheLifetime` option.
* @returns {number} Amount of messages that were removed from the caches,
* or -1 if the message cache lifetime is unlimited
*/
sweepMessages(lifetime = this.options.message_cache_lifetime) {
if (typeof lifetime !== 'number' || isNaN(lifetime)) throw new TypeError('Lifetime must be a number.');
sweepMessages(lifetime = this.options.messageCacheLifetime) {
if (typeof lifetime !== 'number' || isNaN(lifetime)) throw new TypeError('The lifetime must be a number.');
if (lifetime <= 0) {
this.emit('debug', 'Didn\'t sweep messages - lifetime is unlimited');
return -1;
@@ -298,7 +313,7 @@ class Client extends EventEmitter {
channels++;
for (const message of channel.messages.values()) {
if (now - (message._editedTimestamp || message._timestamp) > lifetimeMs) {
if (now - (message.editedTimestamp || message.createdTimestamp) > lifetimeMs) {
channel.messages.delete(message.id);
messages++;
}
@@ -333,6 +348,51 @@ class Client extends EventEmitter {
clearInterval(interval);
this._intervals.delete(interval);
}
_setPresence(id, presence) {
if (this.presences.get(id)) {
this.presences.get(id).update(presence);
return;
}
this.presences.set(id, new Presence(presence));
}
_eval(script) {
return eval(script);
}
_validateOptions(options = this.options) {
if (typeof options.shardCount !== 'number' || isNaN(options.shardCount)) {
throw new TypeError('The shardCount option must be a number.');
}
if (typeof options.shardId !== 'number' || isNaN(options.shardId)) {
throw new TypeError('The shardId option must be a number.');
}
if (options.shardCount < 0) throw new RangeError('The shardCount option must be at least 0.');
if (options.shardId < 0) throw new RangeError('The shardId option must be at least 0.');
if (options.shardId !== 0 && options.shardId >= options.shardCount) {
throw new RangeError('The shardId option must be less than shardCount.');
}
if (typeof options.messageCacheMaxSize !== 'number' || isNaN(options.messageCacheMaxSize)) {
throw new TypeError('The messageCacheMaxSize option must be a number.');
}
if (typeof options.messageCacheLifetime !== 'number' || isNaN(options.messageCacheLifetime)) {
throw new TypeError('The messageCacheLifetime option must be a number.');
}
if (typeof options.messageSweepInterval !== 'number' || isNaN(options.messageSweepInterval)) {
throw new TypeError('The messageSweepInterval option must be a number.');
}
if (typeof options.fetchAllMembers !== 'boolean') {
throw new TypeError('The fetchAllMembers option must be a boolean.');
}
if (typeof options.disableEveryone !== 'boolean') {
throw new TypeError('The disableEveryone option must be a boolean.');
}
if (typeof options.restWsBridgeTimeout !== 'number' || isNaN(options.restWsBridgeTimeout)) {
throw new TypeError('The restWsBridgeTimeout option must be a number.');
}
if (!(options.disabledEvents instanceof Array)) throw new TypeError('The disabledEvents option must be an Array.');
}
}
module.exports = Client;

View File

@@ -3,6 +3,7 @@ const cloneObject = require('../util/CloneObject');
const Guild = require('../structures/Guild');
const User = require('../structures/User');
const DMChannel = require('../structures/DMChannel');
const Emoji = require('../structures/Emoji');
const TextChannel = require('../structures/TextChannel');
const VoiceChannel = require('../structures/VoiceChannel');
const GuildChannel = require('../structures/GuildChannel');
@@ -27,7 +28,7 @@ class ClientDataManager {
* @event Client#guildCreate
* @param {Guild} guild The created guild
*/
if (this.client.options.fetch_all_members) {
if (this.client.options.fetchAllMembers) {
guild.fetchMembers().then(() => { this.client.emit(Constants.Events.GUILD_CREATE, guild); });
} else {
this.client.emit(Constants.Events.GUILD_CREATE, guild);
@@ -73,6 +74,26 @@ class ClientDataManager {
return null;
}
newEmoji(data, guild) {
const already = guild.emojis.has(data.id);
if (data && !already) {
let emoji = new Emoji(guild, data);
this.client.emit(Constants.Events.EMOJI_CREATE, emoji);
guild.emojis.set(emoji.id, emoji);
return emoji;
} else if (already) {
return guild.emojis.get(data.id);
}
return null;
}
killEmoji(emoji) {
if (!(emoji instanceof Emoji && emoji.guild)) return;
this.client.emit(Constants.Events.EMOJI_DELETE, emoji);
emoji.guild.emojis.delete(emoji.id);
}
killGuild(guild) {
const already = this.client.guilds.has(guild.id);
this.client.guilds.delete(guild.id);
@@ -97,6 +118,12 @@ class ClientDataManager {
updateChannel(currentChannel, newData) {
currentChannel.setup(newData);
}
updateEmoji(currentEmoji, newData) {
const oldEmoji = cloneObject(currentEmoji);
currentEmoji.setup(newData);
this.client.emit(Constants.Events.GUILD_EMOJI_UPDATE, oldEmoji, currentEmoji);
}
}
module.exports = ClientDataManager;

View File

@@ -99,23 +99,6 @@ class ClientDataResolver {
return guild.members.get(user.id) || null;
}
/**
* Data that resolves to give a Base64 string, typically for image uploading. This can be:
* * A Buffer
* * A Base64 string
* @typedef {Buffer|string} Base64Resolvable
*/
/**
* Resolves a Base64Resolvable to a Base 64 image
* @param {Base64Resolvable} data The base 64 resolvable you want to resolve
* @returns {?string}
*/
resolveBase64(data) {
if (data instanceof Buffer) return `data:image/jpg;base64,${data.toString('base64')}`;
return data;
}
/**
* Data that can be resolved to give a Channel. This can be:
* * An instance of a Channel
@@ -138,6 +121,26 @@ class ClientDataResolver {
return null;
}
/**
* Data that can be resolved to give an invite code. This can be:
* * An invite code
* * An invite URL
* @typedef {string} InviteResolvable
*/
/**
* Resolves InviteResolvable to an invite code
* @param {InviteResolvable} data The invite resolvable to resolve
* @returns {string}
*/
resolveInviteCode(data) {
const inviteRegex = /discord(?:app)?\.(?:gg|com\/invite)\/([a-z0-9]{5})/i;
const match = inviteRegex.exec(data);
if (match && match[1]) return match[1];
return data;
}
/**
* Data that can be resolved to give a permission number. This can be:
* * A string
@@ -205,6 +208,23 @@ class ClientDataResolver {
return String(data);
}
/**
* Data that resolves to give a Base64 string, typically for image uploading. This can be:
* * A Buffer
* * A Base64 string
* @typedef {Buffer|string} Base64Resolvable
*/
/**
* Resolves a Base64Resolvable to a Base 64 image
* @param {Base64Resolvable} data The base 64 resolvable you want to resolve
* @returns {?string}
*/
resolveBase64(data) {
if (data instanceof Buffer) return `data:image/jpg;base64,${data.toString('base64')}`;
return data;
}
/**
* Data that can be resolved to give a Buffer. This can be:
* * A Buffer

View File

@@ -49,19 +49,20 @@ class ClientManager {
*/
setupKeepAlive(time) {
this.heartbeatInterval = this.client.setInterval(() => {
this.client.emit('debug', 'Sending heartbeat');
this.client.ws.send({
op: Constants.OPCodes.HEARTBEAT,
d: Date.now(),
d: this.client.ws.sequence,
}, true);
}, time);
}
destroy() {
return new Promise((resolve) => {
this.client.ws.destroy();
if (!this.client.user.bot) {
this.client.rest.methods.logout().then(resolve);
} else {
this.client.ws.destroy();
resolve();
}
});

View File

@@ -0,0 +1,46 @@
const Webhook = require('../structures/Webhook');
const RESTManager = require('./rest/RESTManager');
const ClientDataResolver = require('./ClientDataResolver');
const mergeDefault = require('../util/MergeDefault');
const Constants = require('../util/Constants');
/**
* The Webhook Client
* @extends {Webhook}
*/
class WebhookClient extends Webhook {
/**
* @param {string} id The id of the webhook.
* @param {string} token the token of the webhook.
* @param {ClientOptions} [options] Options for the client
* @example
* // create a new webhook and send a message
* let hook = new Discord.WebhookClient('1234', 'abcdef')
* hook.sendMessage('This will send a message').catch(console.log)
*/
constructor(id, token, options) {
super(null, id, token);
/**
* The options the client was instantiated with
* @type {ClientOptions}
*/
this.options = mergeDefault(Constants.DefaultOptions, options);
/**
* The REST manager of the client
* @type {RESTManager}
* @private
*/
this.rest = new RESTManager(this);
/**
* The Data Resolver of the Client
* @type {ClientDataResolver}
* @private
*/
this.resolver = new ClientDataResolver(this);
}
}
module.exports = WebhookClient;

View File

@@ -20,6 +20,10 @@ class ActionsManager {
this.register('UserGet');
this.register('UserUpdate');
this.register('GuildSync');
this.register('GuildEmojiCreate');
this.register('GuildEmojiDelete');
this.register('GuildEmojiUpdate');
this.register('GuildRolesPositionUpdate');
}
register(name) {

View File

@@ -24,7 +24,7 @@ class ChannelDeleteAction extends Action {
}
scheduleForDeletion(id) {
this.client.setTimeout(() => this.deleted.delete(id), this.client.options.rest_ws_bridge_timeout);
this.client.setTimeout(() => this.deleted.delete(id), this.client.options.restWsBridgeTimeout);
}
}

View File

@@ -10,7 +10,7 @@ class ChannelUpdateAction extends Action {
if (channel) {
const oldChannel = cloneObject(channel);
channel.setup(data);
if (!oldChannel.equals(data)) client.emit(Constants.Events.CHANNEL_UPDATE, oldChannel, channel);
client.emit(Constants.Events.CHANNEL_UPDATE, oldChannel, channel);
return {
old: oldChannel,
updated: channel,

View File

@@ -38,7 +38,7 @@ class GuildDeleteAction extends Action {
}
scheduleForDeletion(id) {
this.client.setTimeout(() => this.deleted.delete(id), this.client.options.rest_ws_bridge_timeout);
this.client.setTimeout(() => this.deleted.delete(id), this.client.options.restWsBridgeTimeout);
}
}

View File

@@ -0,0 +1,18 @@
const Action = require('./Action');
class EmojiCreateAction extends Action {
handle(data, guild) {
const client = this.client;
const emoji = client.dataManager.newEmoji(data, guild);
return {
emoji,
};
}
}
/**
* Emitted whenever an emoji is created
* @event Client#guildEmojiCreate
* @param {Emoji} emoji The emoji that was created.
*/
module.exports = EmojiCreateAction;

View File

@@ -0,0 +1,18 @@
const Action = require('./Action');
class EmojiDeleteAction extends Action {
handle(data) {
const client = this.client;
client.dataManager.killEmoji(data);
return {
data,
};
}
}
/**
* Emitted whenever an emoji is deleted
* @event Client#guildEmojiDelete
* @param {Emoji} emoji The emoji that was deleted.
*/
module.exports = EmojiDeleteAction;

View File

@@ -0,0 +1,29 @@
const Action = require('./Action');
class GuildEmojiUpdateAction extends Action {
handle(data, guild) {
const client = this.client;
for (let emoji of data.emojis) {
const already = guild.emojis.has(emoji.id);
if (already) {
client.dataManager.updateEmoji(guild.emojis.get(emoji.id), emoji);
} else {
emoji = client.dataManager.newEmoji(emoji, guild);
}
}
for (let emoji of guild.emojis) {
if (!data.emoijs.has(emoji.id)) client.dataManager.killEmoji(emoji);
}
return {
emojis: data.emojis,
};
}
}
/**
* Emitted whenever an emoji is updated
* @event Client#guildEmojiUpdate
* @param {Emoji} oldEmoji The old emoji
* @param {Emoji} newEmoji The new emoji
*/
module.exports = GuildEmojiUpdateAction;

View File

@@ -17,7 +17,7 @@ class GuildMemberRemoveAction extends Action {
guild.memberCount--;
guild._removeMember(member);
this.deleted.set(guild.id + data.user.id, member);
if (client.status === Constants.Status.READY) client.emit(Constants.Events.GUILD_MEMBER_REMOVE, guild, member);
if (client.status === Constants.Status.READY) client.emit(Constants.Events.GUILD_MEMBER_REMOVE, member);
this.scheduleForDeletion(guild.id, data.user.id);
} else {
member = this.deleted.get(guild.id + data.user.id) || null;
@@ -36,15 +36,14 @@ class GuildMemberRemoveAction extends Action {
}
scheduleForDeletion(guildID, userID) {
this.client.setTimeout(() => this.deleted.delete(guildID + userID), this.client.options.rest_ws_bridge_timeout);
this.client.setTimeout(() => this.deleted.delete(guildID + userID), this.client.options.restWsBridgeTimeout);
}
}
/**
* Emitted whenever a member leaves a guild, or is kicked.
* @event Client#guildMemberRemove
* @param {Guild} guild The guild that the member has left.
* @param {GuildMember} member The member that has left the guild.
* @param {GuildMember} member The member that has left/been kicked from the guild.
*/
module.exports = GuildMemberRemoveAction;

View File

@@ -11,7 +11,7 @@ class GuildRoleCreate extends Action {
const already = guild.roles.has(data.role.id);
const role = new Role(guild, data.role);
guild.roles.set(role.id, role);
if (!already) client.emit(Constants.Events.GUILD_ROLE_CREATE, guild, role);
if (!already) client.emit(Constants.Events.GUILD_ROLE_CREATE, role);
return {
role,
};
@@ -24,9 +24,8 @@ class GuildRoleCreate extends Action {
}
/**
* Emitted whenever a guild role is created.
* @event Client#guildRoleCreate
* @param {Guild} guild The guild that the role was created in.
* Emitted whenever a role is created.
* @event Client#roleCreate
* @param {Role} role The role that was created.
*/

View File

@@ -17,7 +17,7 @@ class GuildRoleDeleteAction extends Action {
guild.roles.delete(data.role_id);
this.deleted.set(guild.id + data.role_id, role);
this.scheduleForDeletion(guild.id, data.role_id);
client.emit(Constants.Events.GUILD_ROLE_DELETE, guild, role);
client.emit(Constants.Events.GUILD_ROLE_DELETE, role);
} else {
role = this.deleted.get(guild.id + data.role_id) || null;
}
@@ -33,14 +33,13 @@ class GuildRoleDeleteAction extends Action {
}
scheduleForDeletion(guildID, roleID) {
this.client.setTimeout(() => this.deleted.delete(guildID + roleID), this.client.options.rest_ws_bridge_timeout);
this.client.setTimeout(() => this.deleted.delete(guildID + roleID), this.client.options.restWsBridgeTimeout);
}
}
/**
* Emitted whenever a guild role is deleted.
* @event Client#guildRoleDelete
* @param {Guild} guild The guild that the role was deleted in.
* @event Client#roleDelete
* @param {Role} role The role that was deleted.
*/

View File

@@ -12,10 +12,10 @@ class GuildRoleUpdateAction extends Action {
let oldRole = null;
const role = guild.roles.get(roleData.id);
if (role && !role.equals(roleData)) {
if (role) {
oldRole = cloneObject(role);
role.setup(data.role);
client.emit(Constants.Events.GUILD_ROLE_UPDATE, guild, oldRole, role);
client.emit(Constants.Events.GUILD_ROLE_UPDATE, oldRole, role);
}
return {
@@ -33,8 +33,7 @@ class GuildRoleUpdateAction extends Action {
/**
* Emitted whenever a guild role is updated.
* @event Client#guildRoleUpdate
* @param {Guild} guild The guild that the role was updated in.
* @event Client#roleUpdate
* @param {Role} oldRole The role before the update.
* @param {Role} newRole The role after the update.
*/

View File

@@ -0,0 +1,23 @@
const Action = require('./Action');
class GuildRolesPositionUpdate extends Action {
handle(data) {
const client = this.client;
const guild = client.guilds.get(data.guild_id);
if (guild) {
for (const partialRole of data.roles) {
const role = guild.roles.get(partialRole.id);
if (role) {
role.position = partialRole.position;
}
}
}
return {
guild,
};
}
}
module.exports = GuildRolesPositionUpdate;

View File

@@ -8,11 +8,7 @@ class GuildSync extends Action {
if (guild) {
data.presences = data.presences || [];
for (const presence of data.presences) {
const user = client.users.get(presence.user.id);
if (user) {
user.status = presence.status;
user.game = presence.game;
}
guild._setPresence(presence.user.id, presence);
}
data.members = data.members || [];

View File

@@ -10,7 +10,7 @@ class GuildUpdateAction extends Action {
if (guild) {
const oldGuild = cloneObject(guild);
guild.setup(data);
if (!oldGuild.equals(data)) client.emit(Constants.Events.GUILD_UPDATE, oldGuild, guild);
client.emit(Constants.Events.GUILD_UPDATE, oldGuild, guild);
return {
old: oldGuild,
updated: guild,

View File

@@ -33,7 +33,7 @@ class MessageDeleteAction extends Action {
scheduleForDeletion(channelID, messageID) {
this.client.setTimeout(() => this.deleted.delete(channelID + messageID),
this.client.options.rest_ws_bridge_timeout);
this.client.options.restWsBridgeTimeout);
}
}

View File

@@ -9,7 +9,7 @@ class MessageUpdateAction extends Action {
const channel = client.channels.get(data.channel_id);
if (channel) {
const message = channel.messages.get(data.id);
if (message && !message.equals(data, true)) {
if (message) {
const oldMessage = cloneObject(message);
message.patch(data);
message._edits.unshift(oldMessage);

View File

@@ -30,11 +30,4 @@ class UserUpdateAction extends Action {
}
}
/**
* Emitted whenever a detail of the logged in User changes - e.g. username.
* @event Client#userUpdate
* @param {ClientUser} oldClientUser The client user before the update.
* @param {ClientUser} newClientUser The client user after the update.
*/
module.exports = UserUpdateAction;

View File

@@ -26,7 +26,7 @@ class RESTManager {
}
getRequestHandler() {
switch (this.client.options.api_request_method) {
switch (this.client.options.apiRequestMethod) {
case 'sequential':
return SequentialRequestHandler;
case 'burst':

View File

@@ -7,13 +7,16 @@ const User = requireStructure('User');
const GuildMember = requireStructure('GuildMember');
const Role = requireStructure('Role');
const Invite = requireStructure('Invite');
const Webhook = requireStructure('Webhook');
const UserProfile = requireStructure('UserProfile');
class RESTMethods {
constructor(restManager) {
this.rest = restManager;
}
loginToken(token) {
loginToken(token = this.rest.client.token) {
token = token.replace(/^Bot\s*/i, '');
return new Promise((resolve, reject) => {
this.rest.client.manager.connectToWebSocket(token, resolve, reject);
});
@@ -26,42 +29,47 @@ class RESTMethods {
this.rest.client.password = password;
this.rest.makeRequest('post', Constants.Endpoints.login, false, { email, password })
.then(data => {
this.rest.client.manager.connectToWebSocket(data.token, resolve, reject);
resolve(this.loginToken(data.token));
})
.catch(reject);
});
}
logout() {
return this.rest.makeRequest('post', Constants.Endpoints.logout, true);
return this.rest.makeRequest('post', Constants.Endpoints.logout, true, {});
}
getGateway() {
return new Promise((resolve, reject) => {
this.rest.makeRequest('get', Constants.Endpoints.gateway, true)
.then(res => {
this.rest.client.ws.gateway = `${res.url}/?encoding=json&v=${this.rest.client.options.protocol_version}`;
this.rest.client.ws.gateway = `${res.url}/?encoding=json&v=${Constants.PROTOCOL_VERSION}`;
resolve(this.rest.client.ws.gateway);
})
.catch(reject);
});
}
sendMessage(channel, content, { tts, nonce, disable_everyone, split } = {}, file = null) {
getBotGateway() {
return this.rest.makeRequest('get', Constants.Endpoints.botGateway, true);
}
sendMessage(channel, content, { tts, nonce, disableEveryone, split } = {}, file = null) {
return new Promise((resolve, reject) => {
if (typeof content !== 'undefined') content = this.rest.client.resolver.resolveString(content);
if (disable_everyone || (typeof disable_everyone === 'undefined' && this.rest.client.options.disable_everyone)) {
content = content.replace('@everyone', '@\u200beveryone').replace('@here', '@\u200bhere');
}
if (content) {
if (disableEveryone || (typeof disableEveryone === 'undefined' && this.rest.client.options.disableEveryone)) {
content = content.replace(/@(everyone|here)/g, '@\u200b$1');
}
if (split) content = splitMessage(content, typeof split === 'object' ? split : {});
if (split) content = splitMessage(content, typeof split === 'object' ? split : {});
}
if (channel instanceof User || channel instanceof GuildMember) {
this.createDM(channel).then(chan => {
this._sendMessageRequest(chan, content, file, tts, nonce, resolve, reject);
})
.catch(reject);
}).catch(reject);
} else {
this._sendMessageRequest(channel, content, file, tts, nonce, resolve, reject);
}
@@ -71,22 +79,24 @@ class RESTMethods {
_sendMessageRequest(channel, content, file, tts, nonce, resolve, reject) {
if (content instanceof Array) {
const datas = [];
const promise = this.rest.makeRequest('post', Constants.Endpoints.channelMessages(channel.id), true, {
let promise = this.rest.makeRequest('post', Constants.Endpoints.channelMessages(channel.id), true, {
content: content[0], tts, nonce,
}, file).catch(reject);
for (let i = 1; i <= content.length; i++) {
if (i < content.length) {
promise.then(data => {
const i2 = i;
promise = promise.then(data => {
datas.push(data);
return this.rest.makeRequest('post', Constants.Endpoints.channelMessages(channel.id), true, {
content: content[i], tts, nonce,
content: content[i2], tts, nonce,
}, file);
});
}).catch(reject);
} else {
promise.then(data => {
datas.push(data);
resolve(this.rest.client.actions.MessageCreate.handle(datas).messages);
});
}).catch(reject);
}
}
} else {
@@ -196,6 +206,26 @@ class RESTMethods {
});
}
createGuild(options) {
options.icon = this.rest.client.resolver.resolveBase64(options.icon) || null;
options.region = options.region || 'us-central';
return new Promise((resolve, reject) => {
this.rest.makeRequest('post', Constants.Endpoints.guilds, true, options)
.then(data => {
if (this.rest.client.guilds.has(data.id)) resolve(this.rest.client.guilds.get(data.id));
const handleGuild = guild => {
if (guild.id === data.id) resolve(guild);
this.rest.client.removeListener('guildCreate', handleGuild);
};
this.rest.client.on('guildCreate', handleGuild);
this.rest.client.setTimeout(() => {
this.rest.client.removeListener('guildCreate', handleGuild);
reject(new Error('Took too long to receive guild data'));
}, 10000);
}).catch(reject);
});
}
// untested but probably will work
deleteGuild(guild) {
return new Promise((resolve, reject) => {
@@ -363,26 +393,27 @@ class RESTMethods {
});
}
banGuildMember(guild, member, deleteDays) {
banGuildMember(guild, member, deleteDays = 0) {
return new Promise((resolve, reject) => {
const id = this.rest.client.resolver.resolveUserID(member);
if (!id) throw new Error('Couldn\'t resolve the user ID to ban.');
this.rest.makeRequest('put', `${Constants.Endpoints.guildBans(guild.id)}/${id}`, true, {
'delete-message-days': deleteDays,
}).then(() => {
if (member instanceof GuildMember) {
resolve(member);
return;
}
const user = this.rest.client.resolver.resolveUser(id);
if (user) {
member = this.rest.client.resolver.resolveGuildMember(guild, user);
resolve(member || user);
return;
}
resolve(id);
}).catch(reject);
this.rest.makeRequest('put',
`${Constants.Endpoints.guildBans(guild.id)}/${id}?delete-message-days=${deleteDays}`, true, {
'delete-message-days': deleteDays,
}).then(() => {
if (member instanceof GuildMember) {
resolve(member);
return;
}
const user = this.rest.client.resolver.resolveUser(id);
if (user) {
member = this.rest.client.resolver.resolveGuildMember(guild, user);
resolve(member || user);
return;
}
resolve(id);
}).catch(reject);
});
}
@@ -419,12 +450,13 @@ class RESTMethods {
return new Promise((resolve, reject) => {
const data = {};
data.name = _data.name || role.name;
data.position = _data.position || role.position;
data.position = typeof _data.position !== 'undefined' ? _data.position : role.position;
data.color = _data.color || role.color;
if (typeof data.color === 'string' && data.color.startsWith('#')) {
data.color = parseInt(data.color.replace('#', ''), 16);
}
data.hoist = typeof _data.hoist !== 'undefined' ? _data.hoist : role.hoist;
data.mentionable = typeof _data.mentionable !== 'undefined' ? _data.mentionable : role.mentionable;
if (_data.permissions) {
let perms = 0;
@@ -516,6 +548,178 @@ class RESTMethods {
}).catch(reject);
});
}
createEmoji(guild, image, name) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('post', `${Constants.Endpoints.guildEmojis(guild.id)}`, true, { name: name, image: image })
.then(data => {
resolve(this.rest.client.actions.EmojiCreate.handle(data, guild).emoji);
}).catch(reject);
});
}
deleteEmoji(emoji) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('delete', `${Constants.Endpoints.guildEmojis(emoji.guild.id)}/${emoji.id}`, true)
.then(() => {
resolve(this.rest.client.actions.EmojiDelete.handle(emoji).data);
}).catch(reject);
});
}
getWebhook(id, token) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('get', Constants.Endpoints.webhook(id, token), require('util').isUndefined(token))
.then(data => {
resolve(new Webhook(this.rest.client, data));
}).catch(reject);
});
}
getGuildWebhooks(guild) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('get', Constants.Endpoints.guildWebhooks(guild.id), true)
.then(data => {
const hooks = new Collection();
for (const hook of data) {
hooks.set(hook.id, new Webhook(this.rest.client, hook));
}
resolve(hooks);
}).catch(reject);
});
}
getChannelWebhooks(channel) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('get', Constants.Endpoints.channelWebhooks(channel.id), true)
.then(data => {
const hooks = new Collection();
for (const hook of data) {
hooks.set(hook.id, new Webhook(this.rest.client, hook));
}
resolve(hooks);
}).catch(reject);
});
}
createWebhook(channel, name, avatar) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('post', Constants.Endpoints.channelWebhooks(channel.id), true, {
name,
avatar,
})
.then(data => {
resolve(new Webhook(this.rest.client, data));
}).catch(reject);
});
}
editWebhook(webhook, name, avatar) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('patch', Constants.Endpoints.webhook(webhook.id, webhook.token), false, {
name,
avatar,
}).then(data => {
webhook.name = data.name;
webhook.avatar = data.avatar;
resolve(webhook);
}).catch(reject);
});
}
deleteWebhook(webhook) {
return this.rest.makeRequest('delete', Constants.Endpoints.webhook(webhook.id, webhook.token), false);
}
sendWebhookMessage(webhook, content, { avatarURL, tts, disableEveryone, embeds } = {}, file = null) {
return new Promise((resolve, reject) => {
if (typeof content !== 'undefined') content = this.rest.client.resolver.resolveString(content);
if (disableEveryone || (typeof disableEveryone === 'undefined' && this.rest.client.options.disableEveryone)) {
content = content.replace('@everyone', '@\u200beveryone').replace('@here', '@\u200bhere');
}
this.rest.makeRequest('post', `${Constants.Endpoints.webhook(webhook.id, webhook.token)}?wait=true`, false, {
content: content, username: webhook.name, avatar_url: avatarURL, tts: tts, file: file, embeds: embeds,
})
.then(data => {
resolve(data);
}).catch(reject);
});
}
sendSlackWebhookMessage(webhook, body) {
return new Promise((resolve, reject) => {
this.rest.makeRequest(
'post',
`${Constants.Endpoints.webhook(webhook.id, webhook.token)}/slack?wait=true`,
false,
body
).then(data => {
resolve(data);
}).catch(reject);
});
}
addFriend(user) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('post', Constants.Endpoints.relationships('@me'), true, {
discriminator: user.discriminator,
username: user.username,
}).then(() => {
resolve(user);
}).catch(reject);
});
}
removeFriend(user) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('delete', `${Constants.Endpoints.relationships('@me')}/${user.id}`, true)
.then(() => {
resolve(user);
}).catch(reject);
});
}
fetchUserProfile(user) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('get', Constants.Endpoints.userProfile(user.id), true)
.then(data => {
resolve(new UserProfile(user, data));
}).catch(reject);
});
}
blockUser(user) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('put', `${Constants.Endpoints.relationships('@me')}/${user.id}`, true, { type: 2 })
.then(() => {
resolve(user);
}).catch(reject);
});
}
unblockUser(user) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('delete', `${Constants.Endpoints.relationships('@me')}/${user.id}`, true)
.then(() => {
resolve(user);
}).catch(reject);
});
}
setRolePositions(guildID, roles) {
return new Promise((resolve, reject) => {
this.rest.makeRequest('patch', Constants.Endpoints.guildRoles(guildID), true, roles)
.then(() => {
resolve(this.rest.client.actions.GuildRolesPositionUpdate.handle({
guild_id: guildID,
roles,
}).guild);
})
.catch(reject);
});
}
}
module.exports = RESTMethods;

View File

@@ -2,6 +2,7 @@ const Collection = require('../../util/Collection');
const mergeDefault = require('../../util/MergeDefault');
const Constants = require('../../util/Constants');
const VoiceConnection = require('./VoiceConnection');
const EventEmitter = require('events').EventEmitter;
/**
* Manages all the voice stuff for the Client
@@ -26,52 +27,17 @@ class ClientVoiceManager {
* @type {Collection<string, VoiceChannel>}
*/
this.pending = new Collection();
this.client.on('self.voiceServer', this.onVoiceServer.bind(this));
this.client.on('self.voiceStateUpdate', this.onVoiceStateUpdate.bind(this));
}
/**
* Checks whether a pending request can be processed
* @private
* @param {string} guildID The ID of the Guild
*/
_checkPendingReady(guildID) {
const pendingRequest = this.pending.get(guildID);
if (!pendingRequest) throw new Error('Guild not pending.');
if (pendingRequest.token && pendingRequest.sessionID && pendingRequest.endpoint) {
const { channel, token, sessionID, endpoint, resolve, reject } = pendingRequest;
const voiceConnection = new VoiceConnection(this, channel, token, sessionID, endpoint, resolve, reject);
this.pending.delete(guildID);
this.connections.set(guildID, voiceConnection);
voiceConnection.once('disconnected', () => {
this.connections.delete(guildID);
});
}
onVoiceServer(data) {
if (this.pending.has(data.guild_id)) this.pending.get(data.guild_id).setTokenAndEndpoint(data.token, data.endpoint);
}
/**
* Called when the Client receives information about this voice server update.
* @param {string} guildID The ID of the Guild
* @param {string} token The token to authorise with
* @param {string} endpoint The endpoint to connect to
*/
_receivedVoiceServer(guildID, token, endpoint) {
const pendingRequest = this.pending.get(guildID);
if (!pendingRequest) throw new Error('Guild not pending.');
pendingRequest.token = token;
// remove the port otherwise it errors ¯\_(ツ)_/¯
pendingRequest.endpoint = endpoint.match(/([^:]*)/)[0];
this._checkPendingReady(guildID);
}
/**
* Called when the Client receives information about the voice state update.
* @param {string} guildID The ID of the Guild
* @param {string} sessionID The session id to authorise with
*/
_receivedVoiceStateUpdate(guildID, sessionID) {
const pendingRequest = this.pending.get(guildID);
if (!pendingRequest) throw new Error('Guild not pending.');
pendingRequest.sessionID = sessionID;
this._checkPendingReady(guildID);
onVoiceStateUpdate(data) {
if (this.pending.has(data.guild_id)) this.pending.get(data.guild_id).setSessionID(data.session_id);
}
/**
@@ -79,13 +45,26 @@ class ClientVoiceManager {
* @param {VoiceChannel} channel The channel to join
* @param {Object} [options] The options to provide
*/
_sendWSJoin(channel, options = {}) {
sendVoiceStateUpdate(channel, options = {}) {
if (!this.client.user) throw new Error('Unable to join because there is no client user.');
if (!channel.permissionsFor) {
throw new Error('Channel does not support permissionsFor; is it really a voice channel?');
}
const permissions = channel.permissionsFor(this.client.user);
if (!permissions) {
throw new Error('There is no permission set for the client user in this channel - are they part of the guild?');
}
if (!permissions.hasPermission('CONNECT')) {
throw new Error('You do not have permission to connect to this voice channel.');
}
options = mergeDefault({
guild_id: channel.guild.id,
channel_id: channel.id,
self_mute: false,
self_deaf: false,
}, options);
this.client.ws.send({
op: Constants.OPCodes.VOICE_STATE_UPDATE,
d: options,
@@ -99,28 +78,171 @@ class ClientVoiceManager {
*/
joinChannel(channel) {
return new Promise((resolve, reject) => {
if (this.pending.get(channel.guild.id)) throw new Error(`Already connecting to a channel in guild.`);
const existingConn = this.connections.get(channel.guild.id);
if (existingConn) {
if (existingConn.channel.id !== channel.id) {
this._sendWSJoin(channel);
if (this.pending.get(channel.guild.id)) throw new Error('Already connecting to this guild\'s voice server.');
if (!channel.permissionsFor(this.client.user).hasPermission('CONNECT')) {
throw new Error('You do not have permission to join this voice channel');
}
const existingConnection = this.connections.get(channel.guild.id);
if (existingConnection) {
if (existingConnection.channel.id !== channel.id) {
this.sendVoiceStateUpdate(channel);
this.connections.get(channel.guild.id).channel = channel;
}
resolve(existingConn);
resolve(existingConnection);
return;
}
this.pending.set(channel.guild.id, {
channel,
sessionID: null,
token: null,
endpoint: null,
resolve,
reject,
const pendingConnection = new PendingVoiceConnection(this, channel);
this.pending.set(channel.guild.id, pendingConnection);
pendingConnection.on('fail', reason => {
this.pending.delete(channel.guild.id);
reject(reason);
});
pendingConnection.on('pass', voiceConnection => {
this.pending.delete(channel.guild.id);
this.connections.set(channel.guild.id, voiceConnection);
voiceConnection.once('ready', () => resolve(voiceConnection));
voiceConnection.once('error', reject);
voiceConnection.once('disconnect', () => this.connections.delete(channel.guild.id));
});
this._sendWSJoin(channel);
this.client.setTimeout(() => reject(new Error('Connection not established within 15 seconds.')), 15000);
});
}
}
/**
* Represents a Pending Voice Connection
* @private
*/
class PendingVoiceConnection extends EventEmitter {
constructor(voiceManager, channel) {
super();
/**
* The ClientVoiceManager that instantiated this pending connection
* @type {ClientVoiceManager}
*/
this.voiceManager = voiceManager;
/**
* The channel that this pending voice connection will attempt to join
* @type {VoiceChannel}
*/
this.channel = channel;
/**
* The timeout that will be invoked after 15 seconds signifying a failure to connect
* @type {Timeout}
*/
this.deathTimer = this.voiceManager.client.setTimeout(
() => this.fail(new Error('Connection not established within 15 seconds.')), 15000);
/**
* An object containing data required to connect to the voice servers with
* @type {object}
*/
this.data = {};
this.sendVoiceStateUpdate();
}
checkReady() {
if (this.data.token && this.data.endpoint && this.data.session_id) {
this.pass();
return true;
} else {
return false;
}
}
/**
* Set the token and endpoint required to connect to the the voice servers
* @param {string} token the token
* @param {string} endpoint the endpoint
* @returns {void}
*/
setTokenAndEndpoint(token, endpoint) {
if (!token) {
this.fail(new Error('Token not provided from voice server packet.'));
return;
}
if (!endpoint) {
this.fail(new Error('Endpoint not provided from voice server packet.'));
return;
}
if (this.data.token) {
this.fail(new Error('There is already a registered token for this connection.'));
return;
}
if (this.data.endpoint) {
this.fail(new Error('There is already a registered endpoint for this connection.'));
return;
}
endpoint = endpoint.match(/([^:]*)/)[0];
if (!endpoint) {
this.fail(new Error('Failed to find an endpoint.'));
return;
}
this.data.token = token;
this.data.endpoint = endpoint;
this.checkReady();
}
/**
* Sets the Session ID for the connection
* @param {string} sessionID the session ID
*/
setSessionID(sessionID) {
if (!sessionID) {
this.fail(new Error('Session ID not supplied.'));
return;
}
if (this.data.session_id) {
this.fail(new Error('There is already a registered session ID for this connection.'));
return;
}
this.data.session_id = sessionID;
this.checkReady();
}
clean() {
clearInterval(this.deathTimer);
this.emit('fail', new Error('Clean-up triggered :fourTriggered:'));
}
pass() {
clearInterval(this.deathTimer);
this.emit('pass', this.upgrade());
}
fail(reason) {
this.emit('fail', reason);
this.clean();
}
sendVoiceStateUpdate() {
try {
this.voiceManager.sendVoiceStateUpdate(this.channel);
} catch (error) {
this.fail(error);
}
}
/**
* Upgrades this Pending Connection to a full Voice Connection
* @returns {VoiceConnection}
*/
upgrade() {
return new VoiceConnection(this);
}
}
module.exports = ClientVoiceManager;

View File

@@ -1,9 +1,10 @@
const VoiceConnectionWebSocket = require('./VoiceConnectionWebSocket');
const VoiceConnectionUDPClient = require('./VoiceConnectionUDPClient');
const VoiceReceiver = require('./receiver/VoiceReceiver');
const VoiceWebSocket = require('./VoiceWebSocket');
const VoiceUDP = require('./VoiceUDPClient');
const Constants = require('../../util/Constants');
const AudioPlayer = require('./player/AudioPlayer');
const VoiceReceiver = require('./receiver/VoiceReceiver');
const EventEmitter = require('events').EventEmitter;
const DefaultPlayer = require('./player/DefaultPlayer');
const fs = require('fs');
/**
* Represents a connection to a Voice Channel in Discord.
@@ -16,89 +17,101 @@ const DefaultPlayer = require('./player/DefaultPlayer');
* @extends {EventEmitter}
*/
class VoiceConnection extends EventEmitter {
constructor(manager, channel, token, sessionID, endpoint, resolve, reject) {
constructor(pendingConnection) {
super();
/**
* The voice manager of this connection
* The Voice Manager that instantiated this connection
* @type {ClientVoiceManager}
* @private
*/
this.manager = manager;
this.voiceManager = pendingConnection.voiceManager;
/**
* The player
* @type {BasePlayer}
*/
this.player = new DefaultPlayer(this);
/**
* The endpoint of the connection
* @type {string}
*/
this.endpoint = endpoint;
/**
* The VoiceChannel for this connection
* The voice channel this connection is currently serving
* @type {VoiceChannel}
*/
this.channel = channel;
this.channel = pendingConnection.channel;
/**
* The WebSocket connection for this voice connection
* @type {VoiceConnectionWebSocket}
* @private
* An array of Voice Receivers that have been created for this connection
* @type {VoiceReceiver[]}
*/
this.websocket = new VoiceConnectionWebSocket(this, channel.guild.id, token, sessionID, endpoint);
/**
* Whether or not the connection is ready
* @type {boolean}
*/
this.ready = false;
/**
* The resolve function for the promise associated with creating this connection
* @type {function}
* @private
*/
this._resolve = resolve;
/**
* The reject function for the promise associated with creating this connection
* @type {function}
* @private
*/
this._reject = reject;
this.ssrcMap = new Map();
this.queue = [];
this.receivers = [];
this.bindListeners();
}
/**
* Executed whenever an error occurs with the UDP/WebSocket sub-client.
* @private
* @param {Error} err The encountered error
*/
_onError(err) {
this._reject(err);
/**
* Emitted whenever the connection encounters a fatal error.
* @event VoiceConnection#error
* @param {Error} error The encountered error
* The authentication data needed to connect to the voice server
* @type {object}
* @private
*/
this.emit('error', err);
this._shutdown(err);
this.authentication = pendingConnection.data;
/**
* The audio player for this voice connection
* @type {AudioPlayer}
*/
this.player = new AudioPlayer(this);
this.player.on('debug', m => {
/**
* Debug info from the connection
* @event VoiceConnection#debug
* @param {string} message the debug message
*/
this.emit('debug', `audio player - ${m}`);
});
this.player.on('error', e => {
/**
* Warning info from the connection
* @event VoiceConnection#warn
* @param {string|error} warning the warning
*/
this.emit('warn', e);
this.player.cleanup();
});
/**
* Map SSRC to speaking values
* @type {Map<number, boolean>}
* @private
*/
this.ssrcMap = new Map();
/**
* Object that wraps contains the `ws` and `udp` sockets of this voice connection
* @type {object}
* @private
*/
this.sockets = {};
this.connect();
}
/**
* Disconnects the Client from the Voice Channel.
* @param {string} [reason='user requested'] The reason of the disconnection
* Sets whether the voice connection should display as "speaking" or not
* @param {boolean} value whether or not to speak
* @private
*/
disconnect(reason = 'user requested') {
this.manager.client.ws.send({
setSpeaking(value) {
if (this.speaking === value) return;
this.speaking = value;
this.sockets.ws.sendPacket({
op: Constants.VoiceOPCodes.SPEAKING,
d: {
speaking: true,
delay: 0,
},
})
.catch(e => {
this.emit('debug', e);
});
}
/**
* Disconnect the voice connection, causing a disconnect and closing event to be emitted.
*/
disconnect() {
this.emit('closing');
this.voiceManager.client.ws.send({
op: Constants.OPCodes.VOICE_STATE_UPDATE,
d: {
guild_id: this.channel.guild.id,
@@ -107,81 +120,51 @@ class VoiceConnection extends EventEmitter {
self_deaf: false,
},
});
this._shutdown(reason);
}
_onClose(e) {
e = e && e.code === 1000 ? null : e;
return this._shutdown(e);
}
_shutdown(e) {
if (!this.ready) return;
this.ready = false;
this.websocket._shutdown();
this.player._shutdown();
if (this.udp) this.udp._shutdown();
if (this._vsUpdateListener) this.manager.client.removeListener('voiceStateUpdate', this._vsUpdateListener);
/**
* Emit once the voice connection has disconnected.
* @event VoiceConnection#disconnected
* @param {Error} error The encountered error, if any
* Emitted when the voice connection disconnects
* @event VoiceConnection#disconnect
*/
this.emit('disconnected', e);
this.emit('disconnect');
}
/**
* Binds listeners to the WebSocket and UDP sub-clients.
* Connect the voice connection
* @private
*/
bindListeners() {
this.websocket.on('error', err => this._onError(err));
this.websocket.on('close', err => this._onClose(err));
this.websocket.on('ready-for-udp', data => {
this.udp = new VoiceConnectionUDPClient(this, data);
this.data = data;
this.udp.on('error', err => this._onError(err));
this.udp.on('close', err => this._onClose(err));
});
this.websocket.on('ready', secretKey => {
this.data.secret = secretKey;
this.ready = true;
connect() {
if (this.sockets.ws) throw new Error('There is already an existing WebSocket connection.');
if (this.sockets.udp) throw new Error('There is already an existing UDP connection.');
this.sockets.ws = new VoiceWebSocket(this);
this.sockets.udp = new VoiceUDP(this);
this.sockets.ws.on('error', e => this.emit('error', e));
this.sockets.udp.on('error', e => this.emit('error', e));
this.sockets.ws.once('ready', d => {
this.authentication.port = d.port;
this.authentication.ssrc = d.ssrc;
/**
* Emitted once the connection is ready (joining voice channels resolves when the connection is ready anyway)
* Emitted whenever the connection encounters an error.
* @event VoiceConnection#error
* @param {Error} error the encountered error
*/
this.sockets.udp.findEndpointAddress()
.then(address => {
this.sockets.udp.createUDPSocket(address);
})
.catch(e => this.emit('error', e));
});
this.sockets.ws.once('sessionDescription', (mode, secret) => {
this.authentication.encryptionMode = mode;
this.authentication.secretKey = secret;
/**
* Emitted once the connection is ready, when a promise to join a voice channel resolves,
* the connection will already be ready.
* @event VoiceConnection#ready
*/
this._resolve(this);
this.emit('ready');
});
this.once('ready', () => {
setImmediate(() => {
for (const item of this.queue) this.emit(...item);
this.queue = [];
});
});
this._vsUpdateListener = (oldM, newM) => {
if (oldM.voiceChannel && oldM.voiceChannel.guild.id === this.channel.guild.id && !newM.voiceChannel) {
const user = newM.user;
for (const receiver of this.receivers) {
const opusStream = receiver.opusStreams.get(user.id);
const pcmStream = receiver.pcmStreams.get(user.id);
if (opusStream) {
opusStream.push(null);
opusStream.open = false;
receiver.opusStreams.delete(user.id);
}
if (pcmStream) {
pcmStream.push(null);
pcmStream.open = false;
receiver.pcmStreams.delete(user.id);
}
}
}
};
this.manager.client.on(Constants.Events.VOICE_STATE_UPDATE, this._vsUpdateListener);
this.websocket.on('speaking', data => {
this.sockets.ws.on('speaking', data => {
const guild = this.channel.guild;
const user = this.manager.client.users.get(data.user_id);
const user = this.voiceManager.client.users.get(data.user_id);
this.ssrcMap.set(+data.ssrc, user);
if (!data.speaking) {
for (const receiver of this.receivers) {
@@ -206,7 +189,6 @@ class VoiceConnection extends EventEmitter {
* @param {boolean} speaking Whether or not the user is speaking
*/
if (this.ready) this.emit('speaking', user, data.speaking);
else this.queue.push(['speaking', user, data.speaking]);
guild._memberSpeakUpdate(data.user_id, data.speaking);
});
}
@@ -232,9 +214,8 @@ class VoiceConnection extends EventEmitter {
* })
* .catch(console.log);
*/
playFile(file, { seek = 0, volume = 1, passes = 1 } = {}) {
const options = { seek, volume, passes };
return this.player.playFile(file, options);
playFile(file, options) {
return this.playStream(fs.createReadStream(file), options);
}
/**
@@ -255,7 +236,7 @@ class VoiceConnection extends EventEmitter {
*/
playStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) {
const options = { seek, volume, passes };
return this.player.playStream(stream, options);
return this.player.playUnknownStream(stream, options);
}
/**
@@ -266,7 +247,6 @@ class VoiceConnection extends EventEmitter {
*/
playConvertedStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) {
const options = { seek, volume, passes };
this.player._shutdown();
return this.player.playPCMStream(stream, options);
}
@@ -275,9 +255,9 @@ class VoiceConnection extends EventEmitter {
* @returns {VoiceReceiver}
*/
createReceiver() {
const rcv = new VoiceReceiver(this);
this.receivers.push(rcv);
return rcv;
const receiver = new VoiceReceiver(this);
this.receivers.push(receiver);
return receiver;
}
}

View File

@@ -1,84 +0,0 @@
const udp = require('dgram');
const dns = require('dns');
const Constants = require('../../util/Constants');
const EventEmitter = require('events').EventEmitter;
class VoiceConnectionUDPClient extends EventEmitter {
constructor(voiceConnection, data) {
super();
this.voiceConnection = voiceConnection;
this.count = 0;
this.data = data;
this.dnsLookup();
}
dnsLookup() {
dns.lookup(this.voiceConnection.endpoint, (err, address) => {
if (err) {
this.emit('error', err);
return;
}
this.connectUDP(address);
});
}
send(packet) {
if (this.udpSocket) {
try {
this.udpSocket.send(packet, 0, packet.length, this.data.port, this.udpIP);
} catch (err) {
this.emit('error', err);
}
}
}
_shutdown() {
if (this.udpSocket) {
try {
this.udpSocket.close();
} catch (err) {
if (err.message !== 'Not running') this.emit('error', err);
}
this.udpSocket = null;
}
}
connectUDP(address) {
this.udpIP = address;
this.udpSocket = udp.createSocket('udp4');
// finding local IP
// https://discordapp.com/developers/docs/topics/voice-connections#ip-discovery
this.udpSocket.once('message', message => {
const packet = new Buffer(message);
this.localIP = '';
for (let i = 4; i < packet.indexOf(0, i); i++) this.localIP += String.fromCharCode(packet[i]);
this.localPort = parseInt(packet.readUIntLE(packet.length - 2, 2).toString(10), 10);
this.voiceConnection.websocket.send({
op: Constants.VoiceOPCodes.SELECT_PROTOCOL,
d: {
protocol: 'udp',
data: {
address: this.localIP,
port: this.localPort,
mode: 'xsalsa20_poly1305',
},
},
});
});
this.udpSocket.on('error', (error, message) => {
this.emit('error', { error, message });
});
this.udpSocket.on('close', error => {
this.emit('close', error);
});
const blankMessage = new Buffer(70);
blankMessage.writeUIntBE(this.data.ssrc, 0, 4);
this.send(blankMessage);
}
}
module.exports = VoiceConnectionUDPClient;

View File

@@ -1,113 +0,0 @@
const WebSocket = require('ws');
const Constants = require('../../util/Constants');
const EventEmitter = require('events').EventEmitter;
class VoiceConnectionWebSocket extends EventEmitter {
constructor(voiceConnection, serverID, token, sessionID, endpoint) {
super();
this.voiceConnection = voiceConnection;
this.token = token;
this.sessionID = sessionID;
this.serverID = serverID;
this.heartbeat = null;
this.opened = false;
this.endpoint = endpoint;
this.attempts = 6;
this.setupWS();
}
setupWS() {
this.attempts--;
this.ws = new WebSocket(`wss://${this.endpoint}`, null, { rejectUnauthorized: false });
this.ws.onopen = () => this._onOpen();
this.ws.onmessage = e => this._onMessage(e);
this.ws.onclose = e => this._onClose(e);
this.ws.onerror = e => this._onError(e);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(data));
}
_shutdown() {
if (this.ws) this.ws.close();
this.voiceConnection.manager.client.clearInterval(this.heartbeat);
}
_onOpen() {
this.opened = true;
this.send({
op: Constants.OPCodes.DISPATCH,
d: {
server_id: this.serverID,
user_id: this.voiceConnection.manager.client.user.id,
session_id: this.sessionID,
token: this.token,
},
});
}
_onClose(err) {
if (!this.opened && this.attempts >= 0) {
this.setupWS();
return;
}
this.emit('close', err);
}
_onError(e) {
if (!this.opened && this.attempts >= 0) {
this.setupWS();
return;
}
this.emit('error', e);
}
_setHeartbeat(interval) {
this.heartbeat = this.voiceConnection.manager.client.setInterval(() => {
this.send({
op: Constants.VoiceOPCodes.HEARTBEAT,
d: null,
});
}, interval);
this.send({
op: Constants.VoiceOPCodes.HEARTBEAT,
d: null,
});
}
_onMessage(event) {
let packet;
try {
packet = JSON.parse(event.data);
} catch (error) {
this._onError(error);
return;
}
switch (packet.op) {
case Constants.VoiceOPCodes.READY:
this._setHeartbeat(packet.d.heartbeat_interval);
this.emit('ready-for-udp', packet.d);
break;
case Constants.VoiceOPCodes.SESSION_DESCRIPTION:
this.encryptionMode = packet.d.mode;
this.secretKey = new Uint8Array(new ArrayBuffer(packet.d.secret_key.length));
for (const index in packet.d.secret_key) this.secretKey[index] = packet.d.secret_key[index];
this.emit('ready', this.secretKey);
break;
case Constants.VoiceOPCodes.SPEAKING:
/*
{ op: 5,
d: { user_id: '123123', ssrc: 1, speaking: true } }
*/
this.emit('speaking', packet.d);
break;
default:
this.emit('unknown', packet);
break;
}
}
}
module.exports = VoiceConnectionWebSocket;

View File

@@ -0,0 +1,145 @@
const udp = require('dgram');
const dns = require('dns');
const Constants = require('../../util/Constants');
const EventEmitter = require('events').EventEmitter;
function parseLocalPacket(message) {
try {
const packet = new Buffer(message);
let address = '';
for (let i = 4; i < packet.indexOf(0, i); i++) address += String.fromCharCode(packet[i]);
const port = parseInt(packet.readUIntLE(packet.length - 2, 2).toString(10), 10);
return { address, port };
} catch (error) {
return { error };
}
}
/**
* Represents a UDP Client for a Voice Connection
* @extends {EventEmitter}
* @private
*/
class VoiceConnectionUDPClient extends EventEmitter {
constructor(voiceConnection) {
super();
/**
* The voice connection that this UDP client serves
* @type {VoiceConnection}
*/
this.voiceConnection = voiceConnection;
/**
* The UDP socket
* @type {?Socket}
*/
this.socket = null;
/**
* The address of the discord voice server
* @type {?string}
*/
this.discordAddress = null;
/**
* The local IP address
* @type {?string}
*/
this.localAddress = null;
/**
* The local port
* @type {?string}
*/
this.localPort = null;
this.voiceConnection.on('closing', this.shutdown.bind(this));
}
shutdown() {
if (this.socket) {
try {
this.socket.close();
} catch (e) {
return;
}
this.socket = null;
}
}
/**
* The port of the discord voice server
* @type {number}
* @readonly
*/
get discordPort() {
return this.voiceConnection.authentication.port;
}
/**
* Tries to resolve the voice server endpoint to an address
* @returns {Promise<string>}
*/
findEndpointAddress() {
return new Promise((resolve, reject) => {
dns.lookup(this.voiceConnection.authentication.endpoint, (error, address) => {
if (error) {
reject(error);
return;
}
this.discordAddress = address;
resolve(address);
});
});
}
/**
* Send a packet to the UDP client
* @param {Object} packet the packet to send
* @returns {Promise<Object>}
*/
send(packet) {
return new Promise((resolve, reject) => {
if (!this.socket) throw new Error('Tried to send a UDP packet, but there is no socket available.');
if (!this.discordAddress || !this.discordPort) throw new Error('Malformed UDP address or port.');
this.socket.send(packet, 0, packet.length, this.discordPort, this.discordAddress, error => {
if (error) reject(error); else resolve(packet);
});
});
}
createUDPSocket(address) {
this.discordAddress = address;
const socket = this.socket = udp.createSocket('udp4');
socket.once('message', message => {
const packet = parseLocalPacket(message);
if (packet.error) {
this.emit('error', packet.error);
return;
}
this.localAddress = packet.address;
this.localPort = packet.port;
this.voiceConnection.sockets.ws.sendPacket({
op: Constants.VoiceOPCodes.SELECT_PROTOCOL,
d: {
protocol: 'udp',
data: {
address: packet.address,
port: packet.port,
mode: 'xsalsa20_poly1305',
},
},
});
});
const blankMessage = new Buffer(70);
blankMessage.writeUIntBE(this.voiceConnection.authentication.ssrc, 0, 4);
this.send(blankMessage);
}
}
module.exports = VoiceConnectionUDPClient;

View File

@@ -0,0 +1,244 @@
const WebSocket = require('ws');
const Constants = require('../../util/Constants');
const SecretKey = require('./util/SecretKey');
const EventEmitter = require('events').EventEmitter;
/**
* Represents a Voice Connection's WebSocket
* @extends {EventEmitter}
* @private
*/
class VoiceWebSocket extends EventEmitter {
constructor(voiceConnection) {
super();
/**
* The Voice Connection that this WebSocket serves
* @type {VoiceConnection}
*/
this.voiceConnection = voiceConnection;
/**
* How many connection attempts have been made
* @type {number}
*/
this.attempts = 0;
this.connect();
this.dead = false;
this.voiceConnection.on('closing', this.shutdown.bind(this));
}
shutdown() {
this.dead = true;
this.reset();
}
/**
* The client of this voice websocket
* @type {Client}
* @readonly
*/
get client() {
return this.voiceConnection.voiceManager.client;
}
/**
* Resets the current WebSocket
*/
reset() {
if (this.ws) {
if (this.ws.readyState !== WebSocket.CLOSED) this.ws.close();
this.ws = null;
}
this.clearHeartbeat();
}
/**
* Starts connecting to the Voice WebSocket Server.
*/
connect() {
if (this.dead) return;
if (this.ws) this.reset();
if (this.attempts > 5) {
this.emit('error', new Error(`Too many connection attempts (${this.attempts}).`));
return;
}
this.attempts++;
/**
* The actual WebSocket used to connect to the Voice WebSocket Server.
* @type {WebSocket}
*/
this.ws = new WebSocket(`wss://${this.voiceConnection.authentication.endpoint}`);
this.ws.onopen = this.onOpen.bind(this);
this.ws.onmessage = this.onMessage.bind(this);
this.ws.onclose = this.onClose.bind(this);
this.ws.onerror = this.onError.bind(this);
}
/**
* Sends data to the WebSocket if it is open.
* @param {string} data the data to send to the WebSocket
* @returns {Promise<string>}
*/
send(data) {
return new Promise((resolve, reject) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error(`Voice websocket not open to send ${data}.`);
}
this.ws.send(data, null, error => {
if (error) reject(error); else resolve(data);
});
});
}
/**
* JSON.stringify's a packet and then sends it to the WebSocket Server.
* @param {Object} packet the packet to send
* @returns {Promise<string>}
*/
sendPacket(packet) {
try {
packet = JSON.stringify(packet);
} catch (error) {
return Promise.reject(error);
}
return this.send(packet);
}
/**
* Called whenever the WebSocket opens
*/
onOpen() {
this.sendPacket({
op: Constants.OPCodes.DISPATCH,
d: {
server_id: this.voiceConnection.channel.guild.id,
user_id: this.client.user.id,
token: this.voiceConnection.authentication.token,
session_id: this.voiceConnection.authentication.session_id,
},
}).catch(() => {
this.emit('error', new Error('Tried to send join packet, but the WebSocket is not open.'));
});
}
/**
* Called whenever a message is received from the WebSocket
* @param {MessageEvent} event the message event that was received
* @returns {void}
*/
onMessage(event) {
try {
return this.onPacket(JSON.parse(event.data));
} catch (error) {
return this.onError(error);
}
}
/**
* Called whenever the connection to the WebSocket Server is lost
*/
onClose() {
// TODO see if the connection is open before reconnecting
if (!this.dead) this.client.setTimeout(this.connect.bind(this), this.attempts * 1000);
}
/**
* Called whenever an error occurs with the WebSocket.
* @param {Error} error the error that occurred
*/
onError(error) {
this.emit('error', error);
}
/**
* Called whenever a valid packet is received from the WebSocket
* @param {Object} packet the received packet
*/
onPacket(packet) {
switch (packet.op) {
case Constants.VoiceOPCodes.READY:
this.setHeartbeat(packet.d.heartbeat_interval);
/**
* Emitted once the voice websocket receives the ready packet
* @param {Object} packet the received packet
* @event VoiceWebSocket#ready
*/
this.emit('ready', packet.d);
break;
case Constants.VoiceOPCodes.SESSION_DESCRIPTION:
/**
* 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
* @event VoiceWebSocket#sessionDescription
*/
this.emit('sessionDescription', packet.d.mode, new SecretKey(packet.d.secret_key));
break;
case Constants.VoiceOPCodes.SPEAKING:
/**
* Emitted whenever a speaking packet is received
* @param {Object} data
* @event VoiceWebSocket#speaking
*/
this.emit('speaking', packet.d);
break;
default:
/**
* Emitted when an unhandled packet is received
* @param {Object} packet
* @event VoiceWebSocket#unknownPacket
*/
this.emit('unknownPacket', packet);
break;
}
}
/**
* Sets an interval at which to send a heartbeat packet to the WebSocket
* @param {number} interval the interval at which to send a heartbeat packet
*/
setHeartbeat(interval) {
if (!interval || isNaN(interval)) {
this.onError(new Error('Tried to set voice heartbeat but no valid interval was specified.'));
return;
}
if (this.heartbeatInterval) {
/**
* Emitted whenver the voice websocket encounters a non-fatal error
* @param {string} warn the warning
* @event VoiceWebSocket#warn
*/
this.emit('warn', 'A voice heartbeat interval is being overwritten');
clearInterval(this.heartbeatInterval);
}
this.heartbeatInterval = this.client.setInterval(this.sendHeartbeat.bind(this), interval);
}
/**
* Clears a heartbeat interval, if one exists
*/
clearHeartbeat() {
if (!this.heartbeatInterval) {
this.emit('warn', 'Tried to clear a heartbeat interval that does not exist');
return;
}
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
/**
* Sends a heartbeat packet
*/
sendHeartbeat() {
this.sendPacket({ op: Constants.VoiceOPCodes.HEARTBEAT, d: null }).catch(() => {
this.emit('warn', 'Tried to send heartbeat, but connection is not open');
this.clearHeartbeat();
});
}
}
module.exports = VoiceWebSocket;

View File

@@ -32,34 +32,25 @@ class StreamDispatcher extends EventEmitter {
this._startStreaming();
this._triggered = false;
this._volume = streamOptions.volume;
/**
* 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}
*/
this.passes = streamOptions.passes || 1;
/**
* Whether playing is paused
* @type {boolean}
*/
this.paused = false;
this.setVolume(streamOptions.volume || 1);
}
/**
* Emitted when the dispatcher starts/stops speaking
* @event StreamDispatcher#speaking
* @param {boolean} value Whether or not the dispatcher is speaking
*/
_setSpeaking(value) {
this.speaking = value;
this.emit('speaking', value);
}
_sendBuffer(buffer, sequence, timestamp) {
let repeats = this.passes;
const packet = this._createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer));
while (repeats--) {
this.player.connection.udp.send(packet);
}
}
/**
* how long the stream dispatcher has been "speaking" for
* How long the stream dispatcher has been "speaking" for
* @type {number}
* @readonly
*/
@@ -68,7 +59,7 @@ class StreamDispatcher extends EventEmitter {
}
/**
* The total time, taking into account pauses and skips, that the dispatcher has been streaming for.
* The total time, taking into account pauses and skips, that the dispatcher has been streaming for
* @type {number}
* @readonly
*/
@@ -76,176 +67,6 @@ class StreamDispatcher extends EventEmitter {
return this.time + this.streamingData.pausedTime;
}
_createPacket(sequence, timestamp, buffer) {
const packetBuffer = new Buffer(buffer.length + 28);
packetBuffer.fill(0);
packetBuffer[0] = 0x80;
packetBuffer[1] = 0x78;
packetBuffer.writeUIntBE(sequence, 2, 2);
packetBuffer.writeUIntBE(timestamp, 4, 4);
packetBuffer.writeUIntBE(this.player.connection.data.ssrc, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12);
buffer = NaCl.secretbox(buffer, nonce, this.player.connection.data.secret);
for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i];
return packetBuffer;
}
_applyVolume(buffer) {
if (this._volume === 1) return buffer;
const out = new Buffer(buffer.length);
for (let i = 0; i < buffer.length; i += 2) {
if (i >= buffer.length - 1) break;
const uint = Math.min(32767, Math.max(-32767, Math.floor(this._volume * buffer.readInt16LE(i))));
out.writeInt16LE(uint, i);
}
return out;
}
_send() {
try {
if (this._triggered) {
this._setSpeaking(false);
return;
}
const data = this.streamingData;
if (data.missed >= 5) {
this._triggerTerminalState('end', 'Stream is not generating quickly enough.');
return;
}
if (this.paused) {
// data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
data.pausedTime += data.length * 10;
this.player.connection.manager.client.setTimeout(() => this._send(), data.length * 10);
return;
}
this._setSpeaking(true);
if (!data.startTime) {
/**
* Emitted once the dispatcher starts streaming
* @event StreamDispatcher#start
*/
this.emit('start');
data.startTime = Date.now();
}
const bufferLength = 1920 * data.channels;
let buffer = this.stream.read(bufferLength);
if (!buffer) {
data.missed++;
data.pausedTime += data.length * 10;
this.player.connection.manager.client.setTimeout(() => this._send(), data.length * 10);
return;
}
data.missed = 0;
if (buffer.length !== bufferLength) {
const newBuffer = new Buffer(bufferLength).fill(0);
buffer.copy(newBuffer);
buffer = newBuffer;
}
buffer = this._applyVolume(buffer);
data.count++;
data.sequence = (data.sequence + 1) < (65536) ? data.sequence + 1 : 0;
data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
this._sendBuffer(buffer, data.sequence, data.timestamp);
const nextTime = data.length + (data.startTime + data.pausedTime + (data.count * data.length) - Date.now());
this.player.connection.manager.client.setTimeout(() => this._send(), nextTime);
} catch (e) {
this._triggerTerminalState('error', e);
}
}
/**
* Emitted once the stream has ended. Attach a `once` listener to this.
* @event StreamDispatcher#end
*/
_triggerEnd() {
this.emit('end');
}
/**
* Emitted once the stream has encountered an error. Attach a `once` listener to this. Also emits `end`.
* @event StreamDispatcher#error
* @param {Error} err The encountered error
*/
_triggerError(err) {
this.emit('end');
this.emit('error', err);
}
_triggerTerminalState(state, err) {
if (this._triggered) return;
/**
* Emitted when the stream wants to give debug information.
* @event StreamDispatcher#debug
* @param {string} information The debug information
*/
this.emit('debug', `Triggered terminal state ${state} - stream is now dead`);
this._triggered = true;
this._setSpeaking(false);
switch (state) {
case 'end':
this._triggerEnd(err);
break;
case 'error':
this._triggerError(err);
break;
default:
this.emit('error', 'Unknown trigger state');
break;
}
}
_startStreaming() {
if (!this.stream) {
this.emit('error', 'No stream');
return;
}
this.stream.on('end', err => this._triggerTerminalState('end', err));
this.stream.on('error', err => this._triggerTerminalState('error', err));
const data = this.streamingData;
data.length = 20;
data.missed = 0;
this.stream.once('readable', () => this._send());
}
_setPaused(paused) {
if (paused) {
this.paused = true;
this._setSpeaking(false);
} else {
this.paused = false;
this._setSpeaking(true);
}
}
/**
* Stops the current stream permanently and emits an `end` event.
*/
end() {
this._triggerTerminalState('end', 'user requested');
}
/**
* The volume of the stream, relative to the stream's input volume
* @type {number}
@@ -292,6 +113,194 @@ class StreamDispatcher extends EventEmitter {
resume() {
this._setPaused(false);
}
/**
* Stops the current stream permanently and emits an `end` event.
*/
end() {
this._triggerTerminalState('end', 'user requested');
}
_setSpeaking(value) {
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);
}
_sendBuffer(buffer, sequence, timestamp) {
let repeats = this.passes;
const packet = this._createPacket(sequence, timestamp, this.player.opusEncoder.encode(buffer));
while (repeats--) {
this.player.voiceConnection.sockets.udp.send(packet)
.catch(e => this.emit('debug', `failed to send a packet ${e}`));
}
}
_createPacket(sequence, timestamp, buffer) {
const packetBuffer = new Buffer(buffer.length + 28);
packetBuffer.fill(0);
packetBuffer[0] = 0x80;
packetBuffer[1] = 0x78;
packetBuffer.writeUIntBE(sequence, 2, 2);
packetBuffer.writeUIntBE(timestamp, 4, 4);
packetBuffer.writeUIntBE(this.player.voiceConnection.authentication.ssrc, 8, 4);
packetBuffer.copy(nonce, 0, 0, 12);
buffer = NaCl.secretbox(buffer, nonce, this.player.voiceConnection.authentication.secretKey.key);
for (let i = 0; i < buffer.length; i++) packetBuffer[i + 12] = buffer[i];
return packetBuffer;
}
_applyVolume(buffer) {
if (this._volume === 1) return buffer;
const out = new Buffer(buffer.length);
for (let i = 0; i < buffer.length; i += 2) {
if (i >= buffer.length - 1) break;
const uint = Math.min(32767, Math.max(-32767, Math.floor(this._volume * buffer.readInt16LE(i))));
out.writeInt16LE(uint, i);
}
return out;
}
_send() {
try {
if (this._triggered) {
this._setSpeaking(false);
return;
}
const data = this.streamingData;
if (data.missed >= 5) {
this._triggerTerminalState('end', 'Stream is not generating quickly enough.');
return;
}
if (this.paused) {
// data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
data.pausedTime += data.length * 10;
this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10);
return;
}
this._setSpeaking(true);
if (!data.startTime) {
/**
* Emitted once the dispatcher starts streaming
* @event StreamDispatcher#start
*/
this.emit('start');
data.startTime = Date.now();
}
const bufferLength = 1920 * data.channels;
let buffer = this.stream.read(bufferLength);
if (!buffer) {
data.missed++;
data.pausedTime += data.length * 10;
this.player.voiceConnection.voiceManager.client.setTimeout(() => this._send(), data.length * 10);
return;
}
data.missed = 0;
if (buffer.length !== bufferLength) {
const newBuffer = new Buffer(bufferLength).fill(0);
buffer.copy(newBuffer);
buffer = newBuffer;
}
buffer = this._applyVolume(buffer);
data.count++;
data.sequence = (data.sequence + 1) < (65536) ? data.sequence + 1 : 0;
data.timestamp = data.timestamp + 4294967295 ? data.timestamp + 960 : 0;
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._send(), nextTime);
} catch (e) {
this._triggerTerminalState('error', e);
}
}
_triggerEnd() {
/**
* Emitted once the stream has ended. Attach a `once` listener to this.
* @event StreamDispatcher#end
*/
this.emit('end');
}
_triggerError(err) {
this.emit('end');
/**
* Emitted once the stream has encountered an error. Attach a `once` listener to this. Also emits `end`.
* @event StreamDispatcher#error
* @param {Error} err The encountered error
*/
this.emit('error', err);
}
_triggerTerminalState(state, err) {
if (this._triggered) return;
/**
* Emitted when the stream wants to give debug information.
* @event StreamDispatcher#debug
* @param {string} information The debug information
*/
this.emit('debug', `Triggered terminal state ${state} - stream is now dead`);
this._triggered = true;
this._setSpeaking(false);
switch (state) {
case 'end':
this._triggerEnd(err);
break;
case 'error':
this._triggerError(err);
break;
default:
this.emit('error', 'Unknown trigger state');
break;
}
}
_startStreaming() {
if (!this.stream) {
this.emit('error', 'No stream');
return;
}
this.stream.on('end', err => this._triggerTerminalState('end', err));
this.stream.on('error', err => this._triggerTerminalState('error', err));
const data = this.streamingData;
data.length = 20;
data.missed = 0;
this.stream.once('readable', () => this._send());
}
_setPaused(paused) {
if (paused) {
this.paused = true;
this._setSpeaking(false);
} else {
this.paused = false;
this._setSpeaking(true);
}
}
}
module.exports = StreamDispatcher;

View File

@@ -1,5 +1,43 @@
const ConverterEngine = require('./ConverterEngine');
const ChildProcess = require('child_process');
const EventEmitter = require('events').EventEmitter;
class PCMConversionProcess extends EventEmitter {
constructor(process) {
super();
this.process = process;
this.input = null;
this.process.on('error', e => this.emit('error', e));
}
setInput(stream) {
this.input = stream;
stream.pipe(this.process.stdin, { end: false });
this.input.on('error', e => this.emit('error', e));
this.process.stdin.on('error', e => this.emit('error', e));
}
destroy() {
this.emit('debug', 'destroying a ffmpeg process:');
if (this.input && this.input.unpipe && this.process.stdin) {
this.input.unpipe(this.process.stdin);
this.emit('unpiped the user input stream from the process input stream');
}
if (this.process.stdin) {
this.process.stdin.end();
this.emit('ended the process stdin');
}
if (this.process.stdin.destroy) {
this.process.stdin.destroy();
this.emit('destroyed the process stdin');
}
if (this.process.kill) {
this.process.kill();
this.emit('killed the process');
}
}
}
class FfmpegConverterEngine extends ConverterEngine {
constructor(player) {
@@ -24,10 +62,7 @@ class FfmpegConverterEngine extends ConverterEngine {
'-ss', String(seek),
'pipe:1',
], { stdio: ['pipe', 'pipe', 'ignore'] });
encoder.on('error', e => this.handleError(encoder, e));
encoder.stdin.on('error', e => this.handleError(encoder, e));
encoder.stdout.on('error', e => this.handleError(encoder, e));
return encoder;
return new PCMConversionProcess(encoder);
}
}

View File

@@ -0,0 +1,80 @@
const PCMConverters = require('../pcm/ConverterEngineList');
const OpusEncoders = require('../opus/OpusEngineList');
const EventEmitter = require('events').EventEmitter;
const StreamDispatcher = require('../dispatcher/StreamDispatcher');
/**
* Represents the Audio Player of a Voice Connection
* @extends {EventEmitter}
* @private
*/
class AudioPlayer extends EventEmitter {
constructor(voiceConnection) {
super();
/**
* The voice connection the player belongs to
* @type {VoiceConnection}
*/
this.voiceConnection = voiceConnection;
this.audioToPCM = new (PCMConverters.fetch())();
this.opusEncoder = OpusEncoders.fetch();
this.currentConverter = null;
/**
* The current stream dispatcher, if a stream is being played
* @type {StreamDispatcher}
*/
this.dispatcher = null;
this.audioToPCM.on('error', e => this.emit('error', e));
this.streamingData = {
channels: 2,
count: 0,
sequence: 0,
timestamp: 0,
pausedTime: 0,
};
this.voiceConnection.on('closing', () => this.cleanup(null, 'voice connection closing'));
}
playUnknownStream(stream, { seek = 0, volume = 1, passes = 1 } = {}) {
const options = { seek, volume, passes };
stream.on('end', () => {
this.emit('debug', 'Input stream to converter has ended');
});
stream.on('error', e => this.emit('error', e));
const conversionProcess = this.audioToPCM.createConvertStream(options.seek);
conversionProcess.on('error', e => this.emit('error', e));
conversionProcess.setInput(stream);
return this.playPCMStream(conversionProcess.process.stdout, conversionProcess, options);
}
cleanup(checkStream, reason) {
// cleanup is a lot less aggressive than v9 because it doesn't try to kill every single stream it is aware of
this.emit('debug', `Clean up triggered due to ${reason}`);
const filter = checkStream && this.dispatcher && this.dispatcher.stream === checkStream;
if (this.currentConverter && (checkStream ? filter : true)) {
this.currentConverter.destroy();
this.currentConverter = null;
}
}
playPCMStream(stream, converter, { seek = 0, volume = 1, passes = 1 } = {}) {
const options = { seek, volume, passes };
stream.on('end', () => this.emit('debug', 'PCM input stream ended'));
this.cleanup(null, 'outstanding play stream');
this.currentConverter = converter;
if (this.dispatcher) {
this.streamingData = this.dispatcher.streamingData;
}
stream.on('error', e => this.emit('error', e));
const dispatcher = new StreamDispatcher(this, stream, this.streamingData, options);
dispatcher.on('error', e => this.emit('error', e));
dispatcher.on('end', () => this.cleanup(dispatcher.stream, 'dispatcher ended'));
dispatcher.on('speaking', value => this.voiceConnection.setSpeaking(value));
this.dispatcher = dispatcher;
dispatcher.on('debug', m => this.emit('debug', `Stream dispatch - ${m}`));
return dispatcher;
}
}
module.exports = AudioPlayer;

View File

@@ -101,6 +101,8 @@ class VoiceConnectionPlayer extends EventEmitter {
speaking: true,
delay: 0,
},
}).catch(e => {
this.emit('debug', e);
});
}

View File

@@ -25,19 +25,22 @@ class VoiceReceiver extends EventEmitter {
this.queues = new Map();
this.pcmStreams = new Map();
this.opusStreams = new Map();
/**
* Whether or not this receiver has been destroyed.
* @type {boolean}
*/
this.destroyed = false;
/**
* The VoiceConnection that instantiated this
* @type {VoiceConnection}
*/
this.connection = connection;
this._listener = (msg => {
this.voiceConnection = connection;
this._listener = msg => {
const ssrc = +msg.readUInt32BE(8).toString(10);
const user = this.connection.ssrcMap.get(ssrc);
const user = this.voiceConnection.ssrcMap.get(ssrc);
if (!user) {
if (!this.queues.has(ssrc)) this.queues.set(ssrc, []);
this.queues.get(ssrc).push(msg);
@@ -50,8 +53,8 @@ class VoiceReceiver extends EventEmitter {
}
this.handlePacket(msg, user);
}
}).bind(this);
this.connection.udp.udpSocket.on('message', this._listener);
};
this.voiceConnection.sockets.udp.socket.on('message', this._listener);
}
/**
@@ -61,7 +64,7 @@ class VoiceReceiver extends EventEmitter {
*/
recreate() {
if (!this.destroyed) return;
this.connection.udp.udpSocket.on('message', this._listener);
this.voiceConnection.sockets.udp.socket.on('message', this._listener);
this.destroyed = false;
return;
}
@@ -70,7 +73,7 @@ class VoiceReceiver extends EventEmitter {
* Destroy this VoiceReceiver, also ending any streams that it may be controlling.
*/
destroy() {
this.connection.udp.udpSocket.removeListener('message', this._listener);
this.voiceConnection.sockets.udp.socket.removeListener('message', this._listener);
for (const stream of this.pcmStreams) {
stream[1]._push(null);
this.pcmStreams.delete(stream[0]);
@@ -89,7 +92,7 @@ class VoiceReceiver extends EventEmitter {
* @returns {ReadableStream}
*/
createOpusStream(user) {
user = this.connection.manager.client.resolver.resolveUser(user);
user = this.voiceConnection.voiceManager.client.resolver.resolveUser(user);
if (!user) throw new Error('Couldn\'t resolve the user to create Opus stream.');
if (this.opusStreams.get(user.id)) throw new Error('There is already an existing stream for that user.');
const stream = new Readable();
@@ -104,7 +107,7 @@ class VoiceReceiver extends EventEmitter {
* @returns {ReadableStream}
*/
createPCMStream(user) {
user = this.connection.manager.client.resolver.resolveUser(user);
user = this.voiceConnection.voiceManager.client.resolver.resolveUser(user);
if (!user) throw new Error('Couldn\'t resolve the user to create PCM stream.');
if (this.pcmStreams.get(user.id)) throw new Error('There is already an existing stream for that user.');
const stream = new Readable();
@@ -114,7 +117,7 @@ class VoiceReceiver extends EventEmitter {
handlePacket(msg, user) {
msg.copy(nonce, 0, 0, 12);
let data = NaCl.secretbox.open(msg.slice(12), nonce, this.connection.data.secret);
let data = NaCl.secretbox.open(msg.slice(12), nonce, this.voiceConnection.authentication.secretKey.key);
if (!data) {
/**
* Emitted whenever a voice packet cannot be decrypted
@@ -141,7 +144,7 @@ class VoiceReceiver extends EventEmitter {
* @param {User} user The user that is sending the buffer (is speaking)
* @param {Buffer} buffer The decoded buffer
*/
const pcm = this.connection.player.opusEncoder.decode(data);
const pcm = this.voiceConnection.player.opusEncoder.decode(data);
if (this.pcmStreams.get(user.id)) this.pcmStreams.get(user.id)._push(pcm);
this.emit('pcm', user, pcm);
}

View File

@@ -0,0 +1,15 @@
/**
* Represents a Secret Key used in encryption over voice
*/
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;

View File

@@ -58,16 +58,25 @@ class WebSocketManager extends EventEmitter {
* @type {?WebSocket}
*/
this.ws = null;
/**
* An object with keys that are websocket event names that should be ignored
* @type {Object}
*/
this.disabledEvents = {};
for (const event in client.options.disabledEvents) this.disabledEvents[event] = true;
this.first = true;
}
/**
* Connects the client to a given gateway
* @param {string} gateway The gateway to connect to
*/
connect(gateway) {
_connect(gateway) {
this.client.emit('debug', `Connecting to gateway ${gateway}`);
this.normalReady = false;
this.status = Constants.Status.CONNECTING;
if (this.status !== Constants.Status.RECONNECTING) this.status = Constants.Status.CONNECTING;
this.ws = new WebSocket(gateway);
this.ws.onopen = () => this.eventOpen();
this.ws.onclose = (d) => this.eventClose(d);
@@ -77,6 +86,15 @@ class WebSocketManager extends EventEmitter {
this._remaining = 3;
}
connect(gateway) {
if (this.first) {
this._connect(gateway);
this.first = false;
} else {
this.client.setTimeout(() => this._connect(gateway), 5500);
}
}
/**
* Sends a packet to the gateway
* @param {Object} data An object that can be JSON stringified
@@ -99,6 +117,7 @@ class WebSocketManager extends EventEmitter {
_send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.emit('send', data);
this.ws.send(data);
}
}
@@ -125,7 +144,7 @@ class WebSocketManager extends EventEmitter {
*/
eventOpen() {
this.client.emit('debug', 'Connection to gateway opened');
if (this.reconnecting) this._sendResume();
if (this.status === Constants.Status.RECONNECTING) this._sendResume();
else this._sendNewIdentify();
}
@@ -133,6 +152,11 @@ class WebSocketManager extends EventEmitter {
* Sends a gateway resume packet, in cases of unexpected disconnections.
*/
_sendResume() {
if (!this.sessionID) {
this._sendNewIdentify();
return;
}
this.client.emit('debug', 'Identifying as resumed session');
const payload = {
token: this.client.token,
session_id: this.sessionID,
@@ -152,13 +176,15 @@ class WebSocketManager extends EventEmitter {
this.reconnecting = false;
const payload = this.client.options.ws;
payload.token = this.client.token;
if (this.client.options.shard_count > 0) {
payload.shard = [Number(this.client.options.shard_id), Number(this.client.options.shard_count)];
if (this.client.options.shardCount > 0) {
payload.shard = [Number(this.client.options.shardId), Number(this.client.options.shardCount)];
}
this.client.emit('debug', 'Identifying as new session');
this.send({
op: Constants.OPCodes.IDENTIFY,
d: payload,
});
this.sequence = -1;
}
/**
@@ -171,6 +197,7 @@ class WebSocketManager extends EventEmitter {
* Emitted whenever the client websocket is disconnected
* @event Client#disconnect
*/
clearInterval(this.client.manager.heartbeatInterval);
if (!this.reconnecting) this.client.emit(Constants.Events.DISCONNECT);
if (event.code === 4004) return;
if (event.code === 4010) return;
@@ -194,7 +221,7 @@ class WebSocketManager extends EventEmitter {
this.client.emit('raw', packet);
if (packet.op === 10) this.client.manager.setupKeepAlive(packet.d.heartbeat_interval);
if (packet.op === Constants.OPCodes.HELLO) this.client.manager.setupKeepAlive(packet.d.heartbeat_interval);
return this.packetManager.handle(packet);
}
@@ -235,10 +262,11 @@ class WebSocketManager extends EventEmitter {
}
if (unavailableCount === 0) {
this.status = Constants.Status.NEARLY;
if (this.client.options.fetch_all_members) {
const promises = this.client.guilds.array().map(g => g.fetchMembers());
if (this.client.options.fetchAllMembers) {
const promises = this.client.guilds.map(g => g.fetchMembers());
Promise.all(promises).then(() => this._emitReady()).catch(e => {
this.client.emit(Constants.Event.WARN, `Error on pre-ready guild member fetching - ${e}`);
this.client.emit(Constants.Events.WARN, 'Error in pre-ready guild member fetching');
this.client.emit(Constants.Events.ERROR, e);
this._emitReady();
});
return;

View File

@@ -42,6 +42,8 @@ class WebSocketPacketManager {
this.register(Constants.WSEvents.MESSAGE_DELETE_BULK, 'MessageDeleteBulk');
this.register(Constants.WSEvents.CHANNEL_PINS_UPDATE, 'ChannelPinsUpdate');
this.register(Constants.WSEvents.GUILD_SYNC, 'GuildSync');
this.register(Constants.WSEvents.RELATIONSHIP_ADD, 'RelationshipAdd');
this.register(Constants.WSEvents.RELATIONSHIP_REMOVE, 'RelationshipRemove');
}
get client() {
@@ -72,17 +74,22 @@ class WebSocketPacketManager {
}
if (packet.op === Constants.OPCodes.INVALID_SESSION) {
this.ws.sessionID = null;
this.ws._sendNewIdentify();
return false;
}
if (this.ws.reconnecting) {
if (packet.op === Constants.OPCodes.HEARTBEAT_ACK) this.ws.client.emit('debug', 'Heartbeat acknowledged');
if (this.ws.status === Constants.Status.RECONNECTING) {
this.ws.reconnecting = false;
this.ws.checkIfReady();
}
this.setSequence(packet.s);
if (this.ws.disabledEvents[packet.t] !== undefined) return false;
if (this.ws.status !== Constants.Status.READY) {
if (BeforeReadyWhitelist.indexOf(packet.t) === -1) {
this.queue.push(packet);

View File

@@ -0,0 +1,13 @@
const AbstractHandler = require('./AbstractHandler');
class GuildEmojiUpdate extends AbstractHandler {
handle(packet) {
const client = this.packetManager.client;
const data = packet.d;
const guild = client.guilds.get(data.guild_id);
if (!guild) return;
client.actions.EmojiUpdate.handle(data, guild);
}
}
module.exports = GuildEmojiUpdate;

View File

@@ -15,14 +15,13 @@ class GuildMembersChunkHandler extends AbstractHandler {
}
guild._checkChunks();
client.emit(Constants.Events.GUILD_MEMBERS_CHUNK, guild, members);
client.emit(Constants.Events.GUILD_MEMBERS_CHUNK, members);
}
}
/**
* Emitted whenever a chunk of Guild members is received
* Emitted whenever a chunk of Guild members is received (all members come from the same guild)
* @event Client#guildMembersChunk
* @param {Guild} guild The guild that the chunks relate to
* @param {GuildMember[]} members The members in the chunk
*/

View File

@@ -18,51 +18,54 @@ class PresenceUpdateHandler extends AbstractHandler {
}
}
const oldUser = cloneObject(user);
user.patch(data.user);
if (!user.equals(oldUser)) {
client.emit(Constants.Events.USER_UPDATE, oldUser, user);
}
if (guild) {
const memberInGuild = guild.members.get(user.id);
if (!memberInGuild && data.status !== 'offline') {
const member = guild._addMember({
let member = guild.members.get(user.id);
if (!member && data.status !== 'offline') {
member = guild._addMember({
user,
roles: data.roles,
deaf: false,
mute: false,
}, false);
client.emit(Constants.Events.GUILD_MEMBER_AVAILABLE, guild, member);
client.emit(Constants.Events.GUILD_MEMBER_AVAILABLE, member);
}
if (member) {
const oldMember = cloneObject(member);
if (member.presence) {
oldMember.frozenPresence = cloneObject(member.presence);
}
guild._setPresence(user.id, data);
client.emit(Constants.Events.PRESENCE_UPDATE, oldMember, member);
} else {
guild._setPresence(user.id, data);
}
}
data.user.username = data.user.username || user.username;
data.user.id = data.user.id || user.id;
data.user.discriminator = data.user.discriminator || user.discriminator;
data.user.status = data.status || user.status;
data.user.game = data.game;
const same = data.user.username === user.username &&
data.user.id === user.id &&
data.user.discriminator === user.discriminator &&
data.user.avatar === user.avatar &&
data.user.status === user.status &&
JSON.stringify(data.user.game) === JSON.stringify(user.game);
if (!same) {
const oldUser = cloneObject(user);
user.patch(data.user);
client.emit(Constants.Events.PRESENCE_UPDATE, oldUser, user);
}
}
}
/**
* Emitted whenever a user changes one of their details or starts/stop playing a game
* Emitted whenever a guild member's presence changes, or they change one of their details.
* @event Client#presenceUpdate
* @param {User} oldUser The user before the presence update
* @param {User} newUser The user after the presence update
* @param {GuildMember} oldMember The member before the presence update
* @param {GuildMember} newMember The member after the presence update
*/
/**
* Emitted whenever a user's details (e.g. username) are changed.
* @event Client#userUpdate
* @param {User} oldUser The user before the update
* @param {User} newUser The user after the update
*/
/**
* Emitted whenever a member becomes available in a large Guild
* @event Client#guildMemberAvailable
* @param {Guild} guild The guild that the member became available in
* @param {GuildMember} member The member that became available
*/

View File

@@ -10,13 +10,28 @@ class ReadyHandler extends AbstractHandler {
const clientUser = new ClientUser(client, data.user);
client.user = clientUser;
client.readyTime = new Date();
client.readyAt = new Date();
client.users.set(clientUser.id, clientUser);
for (const guild of data.guilds) client.dataManager.newGuild(guild);
for (const privateDM of data.private_channels) client.dataManager.newChannel(privateDM);
if (!client.user.bot) client.setInterval(client.syncGuilds.bind(client), 30000);
for (const relation of data.relationships) {
const user = client.dataManager.newUser(relation.user);
if (relation.type === 1) {
client.user.friends.set(user.id, user);
} else if (relation.type === 2) {
client.user.blocked.set(user.id, user);
}
}
data.presences = data.presences || [];
for (const presence of data.presences) {
client.dataManager.newUser(presence.user);
client._setPresence(presence.user.id, presence);
}
if (!client.user.bot && client.options.sync) client.setInterval(client.syncGuilds.bind(client), 30000);
client.once('ready', client.syncGuilds.bind(client));
if (!client.users.has('1')) {

View File

@@ -0,0 +1,19 @@
const AbstractHandler = require('./AbstractHandler');
class RelationshipAddHandler extends AbstractHandler {
handle(packet) {
const client = this.packetManager.client;
const data = packet.d;
if (data.type === 1) {
client.fetchUser(data.id).then(user => {
client.user.friends.set(user.id, user);
});
} else if (data.type === 2) {
client.fetchUser(data.id).then(user => {
client.user.blocked.set(user.id, user);
});
}
}
}
module.exports = RelationshipAddHandler;

View File

@@ -0,0 +1,19 @@
const AbstractHandler = require('./AbstractHandler');
class RelationshipRemoveHandler extends AbstractHandler {
handle(packet) {
const client = this.packetManager.client;
const data = packet.d;
if (data.type === 2) {
if (client.user.blocked.has(data.id)) {
client.user.blocked.delete(data.id);
}
} else if (data.type === 1) {
if (client.user.friends.has(data.id)) {
client.user.friends.delete(data.id);
}
}
}
}
module.exports = RelationshipRemoveHandler;

View File

@@ -12,9 +12,7 @@ class VoiceServerUpdate extends AbstractHandler {
handle(packet) {
const client = this.packetManager.client;
const data = packet.d;
if (client.voice.pending.has(data.guild_id)) {
client.voice._receivedVoiceServer(data.guild_id, data.token, data.endpoint);
}
client.emit('self.voiceServer', data);
}
}

View File

@@ -20,8 +20,8 @@ class VoiceStateUpdateHandler extends AbstractHandler {
// if the member left the voice channel, unset their speaking property
if (!data.channel_id) member.speaking = null;
if (client.voice.pending.has(guild.id) && member.user.id === client.user.id && data.channel_id) {
client.voice._receivedVoiceStateUpdate(data.guild_id, data.session_id);
if (member.user.id === client.user.id && data.channel_id) {
client.emit('self.voiceStateUpdate', data);
}
const newChannel = client.channels.get(data.channel_id);

View File

@@ -1,16 +1,21 @@
const path = require('path');
module.exports = {
Client: require('./client/Client'),
WebhookClient: require('./client/WebhookClient'),
Shard: require('./sharding/Shard'),
ShardClientUtil: require('./sharding/ShardClientUtil'),
ShardingManager: require('./sharding/ShardingManager'),
Collection: require('./util/Collection'),
splitMessage: require('./util/SplitMessage'),
escapeMarkdown: require('./util/EscapeMarkdown'),
fetchRecommendedShards: require('./util/FetchRecommendedShards'),
Channel: require('./structures/Channel'),
ClientUser: require('./structures/ClientUser'),
DMChannel: require('./structures/DMChannel'),
Emoji: require('./structures/Emoji'),
EvaluatedPermissions: require('./structures/EvaluatedPermissions'),
Game: require('./structures/Presence').Game,
GroupDMChannel: require('./structures/GroupDMChannel'),
Guild: require('./structures/Guild'),
GuildChannel: require('./structures/GuildChannel'),
@@ -23,10 +28,12 @@ module.exports = {
PartialGuild: require('./structures/PartialGuild'),
PartialGuildChannel: require('./structures/PartialGuildChannel'),
PermissionOverwrites: require('./structures/PermissionOverwrites'),
Presence: require('./structures/Presence').Presence,
Role: require('./structures/Role'),
TextChannel: require('./structures/TextChannel'),
User: require('./structures/User'),
VoiceChannel: require('./structures/VoiceChannel'),
Webhook: require('./structures/Webhook'),
version: require(path.join(__dirname, '..', 'package')).version,
version: require('../package').version,
};

View File

@@ -1,5 +1,7 @@
const childProcess = require('child_process');
const path = require('path');
const makeError = require('../util/MakeError');
const makePlainError = require('../util/MakePlainError');
/**
* Represents a Shard spawned by the ShardingManager.
@@ -8,35 +10,39 @@ class Shard {
/**
* @param {ShardingManager} manager The sharding manager
* @param {number} id The ID of this shard
* @param {array} [args=[]] Command line arguments to pass to the script
*/
constructor(manager, id) {
constructor(manager, id, args = []) {
/**
* The manager of the spawned shard
* Manager that created the shard
* @type {ShardingManager}
*/
this.manager = manager;
/**
* The shard ID
* ID of the shard
* @type {number}
*/
this.id = id;
/**
* The process of the shard
* The environment variables for the shard
* @type {Object}
*/
this.env = Object.assign({}, process.env, {
SHARD_ID: this.id,
SHARD_COUNT: this.manager.totalShards,
CLIENT_TOKEN: this.manager.token,
});
/**
* Process of the shard
* @type {ChildProcess}
*/
this.process = childProcess.fork(path.resolve(this.manager.file), [], {
env: {
SHARD_ID: this.id,
SHARD_COUNT: this.manager.totalShards,
},
this.process = childProcess.fork(path.resolve(this.manager.file), args, {
env: this.env,
});
this.process.on('message', message => {
this.manager.emit('message', this, message);
});
this.process.on('message', this._handleMessage.bind(this));
this.process.once('exit', () => {
if (this.manager.respawn) this.manager.createShard(this.id);
});
@@ -59,6 +65,38 @@ class Shard {
});
}
/**
* Fetches a Client property value of the shard.
* @param {string} prop Name of the Client property to get, using periods for nesting
* @returns {Promise<*>}
* @example
* shard.fetchClientValue('guilds.size').then(count => {
* console.log(`${count} guilds in shard ${shard.id}`);
* }).catch(console.error);
*/
fetchClientValue(prop) {
if (this._fetches.has(prop)) return this._fetches.get(prop);
const promise = new Promise((resolve, reject) => {
const listener = message => {
if (!message || message._fetchProp !== prop) return;
this.process.removeListener('message', listener);
this._fetches.delete(prop);
resolve(message._result);
};
this.process.on('message', listener);
this.send({ _fetchProp: prop }).catch(err => {
this.process.removeListener('message', listener);
this._fetches.delete(prop);
reject(err);
});
});
this._fetches.set(prop, promise);
return promise;
}
/**
* Evaluates a script on the shard, in the context of the Client.
* @param {string} script JavaScript to run on the shard
@@ -69,20 +107,10 @@ class Shard {
const promise = new Promise((resolve, reject) => {
const listener = message => {
if (!message) return;
if (message._evalResult) {
this.process.removeListener('message', listener);
this._evals.delete(script);
resolve(message._evalResult);
} else if (message._evalError) {
this.process.removeListener('message', listener);
const err = new Error(message._evalError.message, message._evalError.fileName, message._evalError.lineNumber);
err.name = message._evalError.name;
err.columnNumber = message._evalError.columnNumber;
err.stack = message._evalError.stack;
this._evals.delete(script);
reject(err);
}
if (!message || message._eval !== script) return;
this.process.removeListener('message', listener);
this._evals.delete(script);
if (!message._error) resolve(message._result); else reject(makeError(message._error));
};
this.process.on('message', listener);
@@ -98,35 +126,30 @@ class Shard {
}
/**
* Fetches a Client property value of the shard.
* @param {string} prop Name of the Client property to get, using periods for nesting
* @returns {Promise<*>}
* @example
* shard.fetchClientValue('guilds.size').then(count => {
* console.log(`${count} guilds in shard ${shard.id}`);
* }).catch(console.error);
* Handles an IPC message
* @param {*} message Message received
* @private
*/
fetchClientValue(prop) {
if (this._fetches.has(prop)) return this._fetches.get(prop);
_handleMessage(message) {
if (message) {
// Shard is requesting a property fetch
if (message._sFetchProp) {
this.manager.fetchClientValues(message._sFetchProp)
.then(results => this.send({ _sFetchProp: message._sFetchProp, _result: results }))
.catch(err => this.send({ _sFetchProp: message._sFetchProp, _error: makePlainError(err) }));
return;
}
const promise = new Promise((resolve, reject) => {
const listener = message => {
if (typeof message !== 'object' || message._fetchProp !== prop) return;
this.process.removeListener('message', listener);
this._fetches.delete(prop);
resolve(message._fetchPropValue);
};
this.process.on('message', listener);
// Shard is requesting an eval broadcast
if (message._sEval) {
this.manager.broadcastEval(message._sEval)
.then(results => this.send({ _sEval: message._sEval, _result: results }))
.catch(err => this.send({ _sEval: message._sEval, _error: makePlainError(err) }));
return;
}
}
this.send({ _fetchProp: prop }).catch(err => {
this.process.removeListener('message', listener);
this._fetches.delete(prop);
reject(err);
});
});
this._fetches.set(prop, promise);
return promise;
this.manager.emit('message', this, message);
}
}

View File

@@ -0,0 +1,142 @@
const makeError = require('../util/MakeError');
const makePlainError = require('../util/MakePlainError');
/**
* Helper class for sharded clients spawned as a child process, such as from a ShardingManager
*/
class ShardClientUtil {
/**
* @param {Client} client Client of the current shard
*/
constructor(client) {
this.client = client;
process.on('message', this._handleMessage.bind(this));
}
/**
* ID of this shard
* @type {number}
* @readonly
*/
get id() {
return this.client.options.shardId;
}
/**
* Total number of shards
* @type {number}
* @readonly
*/
get count() {
return this.client.options.shardCount;
}
/**
* Sends a message to the master process
* @param {*} message Message to send
* @returns {Promise<void>}
*/
send(message) {
return new Promise((resolve, reject) => {
const sent = process.send(message, err => {
if (err) reject(err); else resolve();
});
if (!sent) throw new Error('Failed to send message to master process.');
});
}
/**
* Fetches a Client property value of each shard.
* @param {string} prop Name of the Client property to get, using periods for nesting
* @returns {Promise<Array>}
* @example
* client.shard.fetchClientValues('guilds.size').then(results => {
* console.log(`${results.reduce((prev, val) => prev + val, 0)} total guilds`);
* }).catch(console.error);
*/
fetchClientValues(prop) {
return new Promise((resolve, reject) => {
const listener = message => {
if (!message || message._sFetchProp !== prop) return;
process.removeListener('message', listener);
if (!message._error) resolve(message._result); else reject(makeError(message._error));
};
process.on('message', listener);
this.send({ _sFetchProp: prop }).catch(err => {
process.removeListener('message', listener);
reject(err);
});
});
}
/**
* Evaluates a script on all shards, in the context of the Clients.
* @param {string} script JavaScript to run on each shard
* @returns {Promise<Array>} Results of the script execution
*/
broadcastEval(script) {
return new Promise((resolve, reject) => {
const listener = message => {
if (!message || message._sEval !== script) return;
process.removeListener('message', listener);
if (!message._error) resolve(message._result); else reject(makeError(message._error));
};
process.on('message', listener);
this.send({ _sEval: script }).catch(err => {
process.removeListener('message', listener);
reject(err);
});
});
}
/**
* Handles an IPC message
* @param {*} message Message received
* @private
*/
_handleMessage(message) {
if (!message) return;
if (message._fetchProp) {
const props = message._fetchProp.split('.');
let value = this.client;
for (const prop of props) value = value[prop];
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) });
} catch (err) {
this._respond('eval', { _eval: message._eval, _error: makePlainError(err) });
}
}
}
/**
* Sends a message to the master process, emitting an error from the client upon failure
* @param {string} type Type of response to send
* @param {*} message Message to send
* @private
*/
_respond(type, message) {
this.send(message).catch(err =>
this.client.emit('error', `Error when sending ${type} response to master process: ${err}`)
);
}
/**
* Creates/gets the singleton of this class
* @param {Client} client Client to use
* @returns {ShardUtil}
*/
static singleton(client) {
if (!this._singleton) {
this._singleton = new this(client);
} else {
client.emit('error', 'Multiple clients created in child process; only the first will handle sharding helpers.');
}
return this._singleton;
}
}
module.exports = ShardClientUtil;

View File

@@ -1,24 +1,35 @@
const path = require('path');
const fs = require('fs');
const EventEmitter = require('events').EventEmitter;
const mergeDefault = require('../util/MergeDefault');
const Shard = require('./Shard');
const Collection = require('../util/Collection');
const fetchRecommendedShards = require('../util/FetchRecommendedShards');
/**
* This is a utility class that can be used to help you spawn shards of your Client. Each shard is completely separate
* from the other. The Shard Manager takes a path to a file and spawns it under the specified amount of shards safely.
* If you do not select an amount of shards, the manager will automatically decide the best amount.
* <warn>The Sharding Manager is still experimental</warn>
* @extends {EventEmitter}
*/
class ShardingManager extends EventEmitter {
/**
* @param {string} file Path to your shard script file
* @param {number} [totalShards=1] Number of shards to default to spawning
* @param {boolean} [respawn=true] Respawn a shard when it dies
* @param {Object} [options] Options for the sharding manager
* @param {number|string} [options.totalShards='auto'] Number of shards to spawn, or "auto"
* @param {boolean} [options.respawn=true] Whether shards should automatically respawn upon exiting
* @param {string[]} [options.shardArgs=[]] Arguments to pass to the shard script when spawning
* @param {string} [options.token] Token to use for automatic shard count and passing to shards
*/
constructor(file, totalShards = 1, respawn = true) {
constructor(file, options = {}) {
super();
options = mergeDefault({
totalShards: 'auto',
respawn: true,
shardArgs: [],
token: null,
}, options);
/**
* Path to the shard script file
@@ -32,16 +43,36 @@ class ShardingManager extends EventEmitter {
/**
* Amount of shards that this manager is going to spawn
* @type {number}
* @type {number|string}
*/
this.totalShards = totalShards;
if (typeof totalShards !== 'number' || isNaN(totalShards)) {
throw new TypeError('Amount of shards must be a number.');
this.totalShards = options.totalShards;
if (this.totalShards !== 'auto') {
if (typeof this.totalShards !== 'number' || isNaN(this.totalShards)) {
throw new TypeError('Amount of shards must be a number.');
}
if (this.totalShards < 1) throw new RangeError('Amount of shards must be at least 1.');
if (this.totalShards !== Math.floor(this.totalShards)) {
throw new RangeError('Amount of shards must be an integer.');
}
}
if (totalShards < 1) throw new RangeError('Amount of shards must be at least 1.');
if (totalShards !== Math.floor(totalShards)) throw new RangeError('Amount of shards must be an integer.');
this.respawn = respawn;
/**
* Whether shards should automatically respawn upon exiting
* @type {boolean}
*/
this.respawn = options.respawn;
/**
* An array of arguments to pass to shards.
* @type {string[]}
*/
this.shardArgs = options.shardArgs;
/**
* Token to use for obtaining the automatic shard count, and passing to shards
* @type {?string}
*/
this.token = options.token ? options.token.replace(/^Bot\s*/i, '') : null;
/**
* A collection of shards that this manager has spawned
@@ -52,11 +83,11 @@ class ShardingManager extends EventEmitter {
/**
* Spawns a single shard.
* @param {number} id The ID of the shard to spawn. THIS IS NOT NEEDED IN ANY NORMAL CASE!
* @param {number} id The ID of the shard to spawn. **This is usually not necessary.**
* @returns {Promise<Shard>}
*/
createShard(id = this.shards.size) {
const shard = new Shard(this, id);
const shard = new Shard(this, id, this.shardArgs);
this.shards.set(id, shard);
/**
* Emitted upon launching a shard
@@ -74,10 +105,29 @@ class ShardingManager extends EventEmitter {
* @returns {Promise<Collection<number, Shard>>}
*/
spawn(amount = this.totalShards, delay = 5500) {
if (typeof amount !== 'number' || isNaN(amount)) throw new TypeError('Amount of shards must be a number.');
if (amount < 1) throw new RangeError('Amount of shards must be at least 1.');
if (amount !== Math.floor(amount)) throw new RangeError('Amount of shards must be an integer.');
return new Promise((resolve, reject) => {
if (amount === 'auto') {
fetchRecommendedShards(this.token).then(count => {
this.totalShards = count;
resolve(this._spawn(count, delay));
}).catch(reject);
} else {
if (typeof amount !== 'number' || isNaN(amount)) throw new TypeError('Amount of shards must be a number.');
if (amount < 1) throw new RangeError('Amount of shards must be at least 1.');
if (amount !== Math.floor(amount)) throw new TypeError('Amount of shards must be an integer.');
resolve(this._spawn(amount, delay));
}
});
}
/**
* Actually spawns shards, unlike that poser above >:(
* @param {number} amount Number of shards to spawn
* @param {number} delay How long to wait in between spawning each shard (in milliseconds)
* @returns {Promise<Collection<number, Shard>>}
* @private
*/
_spawn(amount, delay) {
return new Promise(resolve => {
if (this.shards.size >= amount) throw new Error(`Already spawned ${this.shards.size} shards.`);
this.totalShards = amount;

View File

@@ -32,12 +32,21 @@ class Channel {
}
/**
* The time the channel was created
* The timestamp the channel was created at
* @type {number}
* @readonly
* @type {Date}
*/
get creationDate() {
return new Date((this.id / 4194304) + 1420070400000);
get createdTimestamp() {
return (this.id / 4194304) + 1420070400000;
}
/**
* The time the channel was created
* @type {Date}
* @readonly
*/
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
@@ -47,7 +56,7 @@ class Channel {
* // delete the channel
* channel.delete()
* .then() // success
* .catch(console.log); // log error
* .catch(console.error); // log error
*/
delete() {
return this.client.rest.methods.deleteChannel(this);

View File

@@ -1,4 +1,5 @@
const User = require('./User');
const Collection = require('../util/Collection');
/**
* Represents the logged in client's Discord User
@@ -19,8 +20,22 @@ class ClientUser extends User {
* @type {string}
*/
this.email = data.email;
this.localPresence = {};
this._typing = new Map();
/**
* A Collection of friends for the logged in user.
* <warn>This is only filled for user accounts, not bot accounts!</warn>
* @type {Collection<string, User>}
*/
this.friends = new Collection();
/**
* A Collection of blocked users for the logged in user.
* <warn>This is only filled for user accounts, not bot accounts!</warn>
* @type {Collection<string, User>}
*/
this.blocked = new Collection();
}
edit(data) {
@@ -37,7 +52,7 @@ class ClientUser extends User {
* // set username
* client.user.setUsername('discordjs')
* .then(user => console.log(`My new username is ${user.username}`))
* .catch(console.log);
* .catch(console.error);
*/
setUsername(username) {
return this.client.rest.methods.updateCurrentUser({ username });
@@ -52,7 +67,7 @@ class ClientUser extends User {
* // set email
* client.user.setEmail('bob@gmail.com')
* .then(user => console.log(`My new email is ${user.email}`))
* .catch(console.log);
* .catch(console.error);
*/
setEmail(email) {
return this.client.rest.methods.updateCurrentUser({ email });
@@ -65,9 +80,9 @@ class ClientUser extends User {
* @returns {Promise<ClientUser>}
* @example
* // set password
* client.user.setPassword('password')
* client.user.setPassword('password123')
* .then(user => console.log('New password set!'))
* .catch(console.log);
* .catch(console.error);
*/
setPassword(password) {
return this.client.rest.methods.updateCurrentUser({ password });
@@ -75,68 +90,144 @@ class ClientUser extends User {
/**
* Set the avatar of the logged in Client.
* @param {Base64Resolvable} avatar The new avatar
* @param {FileResolvable|Base64Resolveable} avatar The new avatar
* @returns {Promise<ClientUser>}
* @example
* // set avatar
* client.user.setAvatar(fs.readFileSync('./avatar.png'))
* client.user.setAvatar('./avatar.png')
* .then(user => console.log(`New avatar set!`))
* .catch(console.log);
* .catch(console.error);
*/
setAvatar(avatar) {
return this.client.rest.methods.updateCurrentUser({ avatar });
return new Promise(resolve => {
if (avatar.startsWith('data:')) {
resolve(this.client.rest.methods.updateCurrentUser({ avatar }));
} else {
this.client.resolver.resolveFile(avatar).then(data => {
resolve(this.client.rest.methods.updateCurrentUser({ avatar: data }));
});
}
});
}
/**
* Set the status and playing game of the logged in client.
* @param {string} [status] The status, can be `online` or `idle`
* @param {string|Object} [game] The game that is being played
* @param {string} [url] If you want to display as streaming, set this as the URL to the stream (must be a
* twitch.tv URl)
* Set the status of the logged in user.
* @param {string} status can be `online`, `idle`, `invisible` or `dnd` (do not disturb)
* @returns {Promise<ClientUser>}
* @example
* // set status
* client.user.setStatus('status', 'game')
* .then(user => console.log('Changed status!'))
* .catch(console.log);
*/
setStatus(status, game = null, url = null) {
setStatus(status) {
return this.setPresence({ status });
}
/**
* Set the current game of the logged in user.
* @param {string} game the game being played
* @param {string} [streamingURL] an optional URL to a twitch stream, if one is available.
* @returns {Promise<ClientUser>}
*/
setGame(game, streamingURL) {
return this.setPresence({ game: {
name: game,
url: streamingURL,
} });
}
/**
* Set/remove the AFK flag for the current user.
* @param {boolean} afk whether or not the user is AFK.
* @returns {Promise<ClientUser>}
*/
setAFK(afk) {
return this.setPresence({ afk });
}
/**
* Send a friend request
* <warn>This is only available for user accounts, not bot accounts!</warn>
* @param {UserResolvable} user The user to send the friend request to.
* @returns {Promise<User>} The user the friend request was sent to.
*/
addFriend(user) {
user = this.client.resolver.resolveUser(user);
return this.client.rest.methods.addFriend(user);
}
/**
* Remove a friend
* <warn>This is only available for user accounts, not bot accounts!</warn>
* @param {UserResolvable} user The user to remove from your friends
* @returns {Promise<User>} The user that was removed
*/
removeFriend(user) {
user = this.client.resolver.resolveUser(user);
return this.client.rest.methods.removeFriend(user);
}
/**
* Creates a guild
* <warn>This is only available for user accounts, not bot accounts!</warn>
* @param {string} name The name of the guild
* @param {string} region The region for the server
* @param {FileResolvable|Base64Resolvable} [icon=null] The icon for the guild
* @returns {Promise<Guild>} The guild that was created
*/
createGuild(name, region, icon = null) {
return new Promise(resolve => {
if (status === 'online' || status === 'here' || status === 'available') {
this.idleStatus = null;
} else if (status === 'idle' || status === 'away') {
this.idleStatus = Date.now();
if (!icon) resolve(this.client.rest.methods.createGuild({ name, icon, region }));
if (icon.startsWith('data:')) {
resolve(this.client.rest.methods.createGuild({ name, icon, region }));
} else {
this.idleStatus = this.idleStatus || null;
this.client.resolver.resolveFile(icon).then(data => {
resolve(this.client.rest.methods.createGuild({ name, icon: data, region }));
});
}
});
}
/**
* Set the full presence of the current user.
* @param {Object} data the data to provide
* @returns {Promise<ClientUser>}
*/
setPresence(data) {
// {"op":3,"d":{"status":"dnd","since":0,"game":null,"afk":false}}
return new Promise(resolve => {
let status = this.localPresence.status || this.presence.status;
let game = this.localPresence.game;
let afk = this.localPresence.afk || this.presence.afk;
if (!game && this.presence.game) {
game = {
name: this.presence.game.name,
type: this.presence.game.type,
url: this.presence.game.url,
};
}
if (typeof game === 'string' && !game.length) game = null;
if (game === null) {
this.userGame = null;
} else if (!game) {
this.userGame = this.userGame || null;
} else if (typeof game === 'string') {
this.userGame = { name: game };
} else {
this.userGame = game;
if (data.status) {
if (typeof data.status !== 'string') throw new TypeError('Status must be a string');
status = data.status;
}
if (url) {
this.userGame.url = url;
this.userGame.type = 1;
if (data.game) {
game = data.game;
if (game.url) game.type = 1;
}
if (typeof data.afk !== 'undefined') afk = data.afk;
afk = Boolean(afk);
this.localPresence = { status, game, afk };
this.localPresence.since = 0;
this.localPresence.game = this.localPresence.game || null;
this.client.ws.send({
op: 3,
d: {
idle_since: this.idleStatus,
game: this.userGame,
},
d: this.localPresence,
});
this.status = this.idleStatus ? 'idle' : 'online';
this.game = this.userGame;
this.client._setPresence(this.id, this.localPresence);
resolve(this);
});
}

View File

@@ -51,12 +51,21 @@ class Emoji {
}
/**
* The time the emoji was created
* The timestamp the emoji was created at
* @type {number}
* @readonly
* @type {Date}
*/
get creationDate() {
return new Date((this.id / 4194304) + 1420070400000);
get createdTimestamp() {
return (this.id / 4194304) + 1420070400000;
}
/**
* The time the emoji was created
* @type {Date}
* @readonly
*/
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
@@ -86,7 +95,7 @@ class Emoji {
* @returns {string}
* @example
* // send an emoji:
* const emoji = guild.emojis.array()[0];
* const emoji = guild.emojis.first();
* msg.reply(`Hello! ${emoji}`);
*/
toString() {

View File

@@ -50,7 +50,17 @@ class EvaluatedPermissions {
* @returns {boolean}
*/
hasPermissions(permissions, explicit = false) {
return permissions.map(p => this.hasPermission(p, explicit)).every(v => v);
return permissions.every(p => this.hasPermission(p, explicit));
}
/**
* Checks whether the user has all specified permissions, and lists any missing permissions.
* @param {PermissionResolvable[]} permissions The permissions to check for
* @param {boolean} [explicit=false] Whether to require the user to explicitly have the exact permissions
* @returns {array}
*/
missingPermissions(permissions, explicit = false) {
return permissions.filter(p => !this.hasPermission(p, explicit));
}
}

View File

@@ -80,6 +80,7 @@ class GroupDMChannel extends Channel {
/**
* The owner of this Group DM.
* @type {User}
* @readonly
*/
get owner() {
return this.client.users.get(this.ownerID);
@@ -100,8 +101,8 @@ class GroupDMChannel extends Channel {
this.ownerID === channel.ownerID;
if (equal) {
const thisIDs = this.recipients.array().map(r => r.id);
const otherIDs = channel.recipients.map(r => r.id);
const thisIDs = this.recipients.keyArray();
const otherIDs = channel.recipients.keyArray();
return arraysEqual(thisIDs, otherIDs);
}

View File

@@ -1,6 +1,7 @@
const User = require('./User');
const Role = require('./Role');
const Emoji = require('./Emoji');
const Presence = require('./Presence').Presence;
const GuildMember = require('./GuildMember');
const Constants = require('../util/Constants');
const Collection = require('../util/Collection');
@@ -100,6 +101,12 @@ class Guild {
*/
this.large = data.large || this.large;
/**
* A collection of presences in this Guild
* @type {Collection<string, Presence>}
*/
this.presences = new Collection();
/**
* An array of guild features.
* @type {Object[]}
@@ -137,10 +144,15 @@ class Guild {
*/
this.verificationLevel = data.verification_level;
/**
* The timestamp the client user joined the guild at
* @type {number}
*/
this.joinedTimestamp = data.joined_at ? new Date(data.joined_at).getTime() : this.joinedTimestamp;
this.id = data.id;
this.available = !data.unavailable;
this.features = data.features || this.features || [];
this._joinedTimestamp = data.joined_at ? new Date(data.joined_at).getTime() : this._joinedTimestamp;
if (data.members) {
this.members.clear();
@@ -170,11 +182,7 @@ class Guild {
if (data.presences) {
for (const presence of data.presences) {
const user = this.client.users.get(presence.user.id);
if (user) {
user.status = presence.status;
user.game = presence.game;
}
this._setPresence(presence.user.id, presence);
}
}
@@ -197,20 +205,30 @@ class Guild {
}
/**
* The time the guild was created
* The timestamp the guild was created at
* @type {number}
* @readonly
* @type {Date}
*/
get creationDate() {
return new Date((this.id / 4194304) + 1420070400000);
get createdTimestamp() {
return (this.id / 4194304) + 1420070400000;
}
/**
* The date at which the logged-in client joined the guild.
* The time the guild was created
* @type {Date}
* @readonly
*/
get joinDate() {
return new Date(this._joinedTimestamp);
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
* The time the client user joined the guild
* @type {Date}
* @readonly
*/
get joinedAt() {
return new Date(this.joinedTimestamp);
}
/**
@@ -278,6 +296,14 @@ class Guild {
return this.client.rest.methods.getGuildInvites(this);
}
/**
* Fetch all webhooks for the guild.
* @returns {Collection<Webhook>}
*/
fetchWebhooks() {
return this.client.rest.methods.getGuildWebhooks(this);
}
/**
* Fetch a single guild member from a user.
* @param {UserResolvable} user The user to fetch the member for
@@ -329,7 +355,7 @@ class Guild {
* region: 'london',
* })
* .then(updated => console.log(`New guild name ${updated.name} in region ${updated.region}`))
* .catch(console.log);
* .catch(console.error);
*/
edit(data) {
return this.client.rest.methods.updateGuild(this, data);
@@ -343,7 +369,7 @@ class Guild {
* // edit the guild name
* guild.setName('Discord Guild')
* .then(updated => console.log(`Updated guild name to ${guild.name}`))
* .catch(console.log);
* .catch(console.error);
*/
setName(name) {
return this.edit({ name });
@@ -357,7 +383,7 @@ class Guild {
* // edit the guild region
* guild.setRegion('london')
* .then(updated => console.log(`Updated guild region to ${guild.region}`))
* .catch(console.log);
* .catch(console.error);
*/
setRegion(region) {
return this.edit({ region });
@@ -371,7 +397,7 @@ class Guild {
* // edit the guild verification level
* guild.setVerificationLevel(1)
* .then(updated => console.log(`Updated guild verification level to ${guild.verificationLevel}`))
* .catch(console.log);
* .catch(console.error);
*/
setVerificationLevel(verificationLevel) {
return this.edit({ verificationLevel });
@@ -385,7 +411,7 @@ class Guild {
* // edit the guild AFK channel
* guild.setAFKChannel(channel)
* .then(updated => console.log(`Updated guild AFK channel to ${guild.afkChannel}`))
* .catch(console.log);
* .catch(console.error);
*/
setAFKChannel(afkChannel) {
return this.edit({ afkChannel });
@@ -399,7 +425,7 @@ class Guild {
* // edit the guild AFK channel
* guild.setAFKTimeout(60)
* .then(updated => console.log(`Updated guild AFK timeout to ${guild.afkTimeout}`))
* .catch(console.log);
* .catch(console.error);
*/
setAFKTimeout(afkTimeout) {
return this.edit({ afkTimeout });
@@ -413,7 +439,7 @@ class Guild {
* // edit the guild icon
* guild.setIcon(fs.readFileSync('./icon.png'))
* .then(updated => console.log('Updated the guild icon'))
* .catch(console.log);
* .catch(console.error);
*/
setIcon(icon) {
return this.edit({ icon });
@@ -427,7 +453,7 @@ class Guild {
* // edit the guild owner
* guild.setOwner(guilds.members[0])
* .then(updated => console.log(`Updated the guild owner to ${updated.owner.username}`))
* .catch(console.log);
* .catch(console.error);
*/
setOwner(owner) {
return this.edit({ owner });
@@ -441,7 +467,7 @@ class Guild {
* // edit the guild splash
* guild.setIcon(fs.readFileSync('./splash.png'))
* .then(updated => console.log('Updated the guild splash'))
* .catch(console.log);
* .catch(console.error);
*/
setSplash(splash) {
return this.edit({ splash });
@@ -514,7 +540,7 @@ class Guild {
* // create a new text channel
* guild.createChannel('new-general', 'text')
* .then(channel => console.log(`Created new channel ${channel}`))
* .catch(console.log);
* .catch(console.error);
*/
createChannel(name, type) {
return this.client.rest.methods.createChannel(this, name, type);
@@ -528,12 +554,12 @@ class Guild {
* // create a new role
* guild.createRole()
* .then(role => console.log(`Created role ${role}`))
* .catch(console.log);
* .catch(console.error);
* @example
* // create a new role with data
* guild.createRole({ name: 'Super Cool People' })
* .then(role => console.log(`Created role ${role}`))
* .catch(console.log)
* .catch(console.error)
*/
createRole(data) {
const create = this.client.rest.methods.createGuildRole(this);
@@ -541,6 +567,43 @@ class Guild {
return create.then(role => role.edit(data));
}
/**
* Creates a new custom emoji in the guild.
* @param {FileResolveable} attachment The image for the emoji.
* @param {string} name The name for the emoji.
* @returns {Promise<Emoji>} 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) {
return new Promise((resolve, reject) => {
this.client.resolver.resolveFile(attachment).then(file => {
let base64 = new Buffer(file, 'binary').toString('base64');
let dataURI = `data:;base64,${base64}`;
this.client.rest.methods.createEmoji(this, dataURI, name)
.then(resolve).catch(reject);
}).catch(reject);
});
}
/**
* Delete an emoji.
* @param {Emoji|string} emoji The emoji to delete.
* @returns {Promise}
*/
deleteEmoji(emoji) {
if (!(emoji instanceof Emoji)) emoji = this.emojis.get(emoji);
return this.client.rest.methods.deleteEmoji(emoji);
}
/**
* Causes the Client to leave the guild.
* @returns {Promise<Guild>}
@@ -548,7 +611,7 @@ class Guild {
* // leave a guild
* guild.leave()
* .then(g => console.log(`Left the guild ${g}`))
* .catch(console.log);
* .catch(console.error);
*/
leave() {
return this.client.rest.methods.leaveGuild(this);
@@ -561,12 +624,38 @@ class Guild {
* // delete a guild
* guild.delete()
* .then(g => console.log(`Deleted the guild ${g}`))
* .catch(console.log);
* .catch(console.error);
*/
delete() {
return this.client.rest.methods.deleteGuild(this);
}
/**
* Set the position of a role in this guild
* @param {string|Role} role the role to edit, can be a role object or a role ID.
* @param {number} position the new position of the role
* @returns {Promise<Guild>}
*/
setRolePosition(role, position) {
if (role instanceof Role) {
role = role.id;
} else if (typeof role !== 'string') {
return Promise.reject(new Error('Supplied role is not a role or string'));
}
position = Number(position);
if (isNaN(position)) {
return Promise.reject(new Error('Supplied position is not a number'));
}
const updatedRoles = this.roles.array().map(r => ({
id: r.id,
position: r.id === role ? position : (r.position < position ? r.position : r.position + 1),
}));
return this.client.rest.methods.setRolePositions(this.id, updatedRoles);
}
/**
* Whether this Guild equals another Guild. It compares all properties, so for most operations
* it is advisable to just compare `guild.id === guild2.id` as it is much faster and is often
@@ -616,6 +705,7 @@ class Guild {
}
_addMember(guildUser, emitEvent = true) {
const existing = this.members.has(guildUser.user.id);
if (!(guildUser.user instanceof User)) guildUser.user = this.client.dataManager.newUser(guildUser.user);
guildUser.joined_at = guildUser.joined_at || 0;
@@ -636,11 +726,10 @@ class Guild {
/**
* Emitted whenever a user joins a guild.
* @event Client#guildMemberAdd
* @param {Guild} guild The guild that the user has joined
* @param {GuildMember} member The member that has joined
* @param {GuildMember} member The member that has joined a guild
*/
if (this.client.ws.status === Constants.Status.READY && emitEvent) {
this.client.emit(Constants.Events.GUILD_MEMBER_ADD, this, member);
if (this.client.ws.status === Constants.Status.READY && emitEvent && !existing) {
this.client.emit(Constants.Events.GUILD_MEMBER_ADD, member);
}
this._checkChunks();
@@ -659,11 +748,10 @@ class Guild {
/**
* Emitted whenever a Guild Member changes - i.e. new role, removed role, nickname
* @event Client#guildMemberUpdate
* @param {Guild} guild The guild that the update affects
* @param {GuildMember} oldMember The member before the update
* @param {GuildMember} newMember The member after the update
*/
this.client.emit(Constants.Events.GUILD_MEMBER_UPDATE, this, oldMember, member);
this.client.emit(Constants.Events.GUILD_MEMBER_UPDATE, oldMember, member);
}
return {
@@ -691,6 +779,14 @@ class Guild {
}
}
_setPresence(id, presence) {
if (this.presences.get(id)) {
this.presences.get(id).update(presence);
return;
}
this.presences.set(id, new Presence(presence));
}
_checkChunks() {
if (this._fetchWaiter) {
if (this.members.size === this.memberCount) {

View File

@@ -111,7 +111,7 @@ class GuildChannel extends Channel {
/**
* Overwrites the permissions for a user or role in this channel.
* @param {Role|UserResolvable} userOrRole The user or role to update
* @param {RoleResolvable|UserResolvable} userOrRole The user or role to update
* @param {PermissionOverwriteOptions} options The configuration for the update
* @returns {Promise}
* @example
@@ -120,7 +120,7 @@ class GuildChannel extends Channel {
* SEND_MESSAGES: false
* })
* .then(() => console.log('Done!'))
* .catch(console.log);
* .catch(console.error);
*/
overwritePermissions(userOrRole, options) {
const payload = {
@@ -130,6 +130,9 @@ class GuildChannel extends Channel {
if (userOrRole instanceof Role) {
payload.type = 'role';
} else if (this.guild.roles.has(userOrRole)) {
userOrRole = this.guild.roles.get(userOrRole);
payload.type = 'role';
} else {
userOrRole = this.client.resolver.resolveUser(userOrRole);
payload.type = 'member';
@@ -141,8 +144,8 @@ class GuildChannel extends Channel {
const prevOverwrite = this.permissionOverwrites.get(userOrRole.id);
if (prevOverwrite) {
payload.allow = prevOverwrite.allow;
payload.deny = prevOverwrite.deny;
payload.allow = prevOverwrite.allowData;
payload.deny = prevOverwrite.denyData;
}
for (const perm in options) {
@@ -170,7 +173,7 @@ class GuildChannel extends Channel {
* // set a new channel name
* channel.setName('not_general')
* .then(newChannel => console.log(`Channel's new name is ${newChannel.name}`))
* .catch(console.log);
* .catch(console.error);
*/
setName(name) {
return this.client.rest.methods.updateChannel(this, { name });
@@ -184,7 +187,7 @@ class GuildChannel extends Channel {
* // set a new channel position
* channel.setPosition(2)
* .then(newChannel => console.log(`Channel's new position is ${newChannel.position}`))
* .catch(console.log);
* .catch(console.error);
*/
setPosition(position) {
return this.client.rest.methods.updateChannel(this, { position });
@@ -198,7 +201,7 @@ class GuildChannel extends Channel {
* // set a new channel topic
* channel.setTopic('needs more rate limiting')
* .then(newChannel => console.log(`Channel's new topic is ${newChannel.topic}`))
* .catch(console.log);
* .catch(console.error);
*/
setTopic(topic) {
return this.client.rest.methods.updateChannel(this, { topic });
@@ -236,12 +239,12 @@ class GuildChannel extends Channel {
this.name === channel.name;
if (equal) {
if (channel.permission_overwrites) {
const thisIDSet = Array.from(this.permissionOverwrites.keys());
const otherIDSet = channel.permission_overwrites.map(overwrite => overwrite.id);
if (this.permissionOverwrites && channel.permissionOverwrites) {
const thisIDSet = this.permissionOverwrites.keyArray();
const otherIDSet = channel.permissionOverwrites.keyArray();
equal = arraysEqual(thisIDSet, otherIDSet);
} else {
equal = false;
equal = !this.permissionOverwrites && !channel.permissionOverwrites;
}
}

View File

@@ -82,17 +82,32 @@ class GuildMember {
*/
this.nickname = data.nick || null;
/**
* The timestamp the member joined the guild at
* @type {number}
*/
this.joinedTimestamp = new Date(data.joined_at).getTime();
this.user = data.user;
this._roles = data.roles;
this._joinDate = new Date(data.joined_at).getTime();
}
/**
* The date this member joined the guild
* The time the member joined the guild
* @type {Date}
* @readonly
*/
get joinDate() {
return new Date(this._joinDate);
get joinedAt() {
return new Date(this.joinedTimestamp);
}
/**
* The presence of this Guild Member
* @type {Presence}
* @readonly
*/
get presence() {
return this.frozenPresence || this.guild.presences.get(this.id);
}
/**
@@ -117,11 +132,10 @@ class GuildMember {
/**
* The role of the member with the highest position.
* @type {Role}
* @readonly
*/
get highestRole() {
return this.roles.reduce((prev, role) =>
!prev || role.position > prev.position || (role.position === prev.position && role.id < prev.id) ? role : prev
);
return this.roles.reduce((prev, role) => !prev || role.comparePositionTo(prev) > 0 ? role : prev);
}
/**
@@ -163,6 +177,7 @@ class GuildMember {
/**
* The overall set of permissions for the guild member, taking only roles into account
* @type {EvaluatedPermissions}
* @readonly
*/
get permissions() {
if (this.user.id === this.guild.ownerID) return new EvaluatedPermissions(this, Constants.ALL_PERMISSIONS);
@@ -180,25 +195,27 @@ class GuildMember {
/**
* 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.member(this.client.member);
const clientMember = this.guild.member(this.client.user);
if (!clientMember.hasPermission(Constants.PermissionFlags.KICK_MEMBERS)) return false;
return clientMember.highestRole.position > this.highestRole.positon;
return clientMember.highestRole.comparePositionTo(this.highestRole) > 0;
}
/**
* Whether the member is bannable by the client user.
* @type {boolean}
* @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.member(this.client.member);
const clientMember = this.guild.member(this.client.user);
if (!clientMember.hasPermission(Constants.PermissionFlags.BAN_MEMBERS)) return false;
return clientMember.highestRole.position > this.highestRole.positon;
return clientMember.highestRole.comparePositionTo(this.highestRole) > 0;
}
/**
@@ -231,7 +248,17 @@ class GuildMember {
*/
hasPermissions(permissions, explicit = false) {
if (!explicit && this.user.id === this.guild.ownerID) return true;
return permissions.map(p => this.hasPermission(p, explicit)).every(v => v);
return permissions.every(p => this.hasPermission(p, explicit));
}
/**
* Checks whether the roles of the member allows them to perform specific actions, and lists any missing permissions.
* @param {PermissionResolvable[]} permissions The permissions to check for
* @param {boolean} [explicit=false] Whether to require the member to explicitly have the exact permissions
* @returns {array}
*/
missingPermissions(permissions, explicit = false) {
return permissions.filter(p => !this.hasPermission(p, explicit));
}
/**
@@ -380,7 +407,7 @@ class GuildMember {
* console.log(`Hello from ${member}!`);
*/
toString() {
return String(this.user);
return `<@${this.nickname ? '!' : ''}${this.user.id}>`;
}
// These are here only for documentation purposes - they are implemented by TextBasedChannel

View File

@@ -92,23 +92,38 @@ class Invite {
*/
this.channel = this.client.channels.get(data.channel.id) || new PartialGuildChannel(this.client, data.channel);
this._createdAt = new Date(data.created_at).getTime();
/**
* The timestamp the invite was created at
* @type {number}
*/
this.createdTimestamp = new Date(data.created_at).getTime();
}
/**
* The creation date of the invite
* The time the invite was created
* @type {Date}
* @readonly
*/
get createdAt() {
return new Date(this._createdAt);
return new Date(this.createdTimestamp);
}
/**
* The creation date of the invite
* @type {Date}
* The timestamp the invite will expire at
* @type {number}
* @readonly
*/
get creationDate() {
return new Date(this._createdAt);
get expiresTimestamp() {
return this.createdTimestamp + (this.maxAge * 1000);
}
/**
* The time the invite will expire
* @type {Date}
* @readonly
*/
get expiresAt() {
return new Date(this.expiresTimestamp);
}
/**

View File

@@ -2,6 +2,7 @@ const Attachment = require('./MessageAttachment');
const Embed = require('./MessageEmbed');
const Collection = require('../util/Collection');
const Constants = require('../util/Constants');
const escapeMarkdown = require('../util/EscapeMarkdown');
/**
* Represents a Message on Discord
@@ -31,6 +32,12 @@ class Message {
*/
this.id = data.id;
/**
* The type of the message
* @type {string}
*/
this.type = Constants.MessageTypes[data.type];
/**
* The content of the message
* @type {string}
@@ -87,6 +94,18 @@ class Message {
this.attachments = new Collection();
for (const attachment of data.attachments) this.attachments.set(attachment.id, new Attachment(this, attachment));
/**
* The timestamp the message was sent at
* @type {number}
*/
this.createdTimestamp = new Date(data.timestamp).getTime();
/**
* The timestamp the message was last edited at (if applicable)
* @type {?number}
*/
this.editedTimestamp = data.edited_timestamp ? new Date(data.edited_timestamp).getTime() : null;
/**
* An object containing a further users, roles or channels collections
* @type {Object}
@@ -128,8 +147,6 @@ class Message {
}
}
this._timestamp = new Date(data.timestamp).getTime();
this._editedTimestamp = data.edited_timestamp ? new Date(data.edited_timestamp).getTime() : null;
this._edits = [];
}
@@ -139,9 +156,9 @@ class Message {
if (this.guild) this.member = this.guild.member(this.author);
}
if (data.content) this.content = data.content;
if (data.timestamp) this._timestamp = new Date(data.timestamp).getTime();
if (data.timestamp) this.createdTimestamp = new Date(data.timestamp).getTime();
if (data.edited_timestamp) {
this._editedTimestamp = data.edited_timestamp ? new Date(data.edited_timestamp).getTime() : null;
this.editedTimestamp = data.edited_timestamp ? new Date(data.edited_timestamp).getTime() : null;
}
if ('tts' in data) this.tts = data.tts;
if ('mention_everyone' in data) this.mentions.everyone = data.mention_everyone;
@@ -185,24 +202,27 @@ class Message {
}
/**
* When the message was sent
* The time the message was sent
* @type {Date}
* @readonly
*/
get timestamp() {
return new Date(this._timestamp);
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
* If the message was edited, the timestamp at which it was last edited
* The time the message was last edited at (if applicable)
* @type {?Date}
* @readonly
*/
get editedTimestamp() {
return new Date(this._editedTimestamp);
get editedAt() {
return this.editedTimestamp ? new Date(this.editedTimestamp) : null;
}
/**
* The guild the message was sent in (if in a guild channel)
* @type {?Guild}
* @readonly
*/
get guild() {
return this.channel.guild || null;
@@ -212,11 +232,11 @@ class Message {
* The message contents with all mentions replaced by the equivalent text. If mentions cannot be resolved to a name,
* the relevant mention in the message content will not be converted.
* @type {string}
* @readonly
*/
get cleanContent() {
return this.content
.replace(/@everyone/g, '@\u200Beveryone')
.replace(/@here/g, '@\u200Bhere')
.replace(/@(everyone|here)/g, '@\u200b$1')
.replace(/<@!?[0-9]+>/g, (input) => {
const id = input.replace(/<|!|>|@/g, '');
if (this.channel.type === 'dm' || this.channel.type === 'group') {
@@ -250,6 +270,7 @@ class Message {
* An array of cached versions of the message, including the current version.
* Sorted from latest (first) to oldest (last).
* @type {Message[]}
* @readonly
*/
get edits() {
return this._edits.slice().unshift(this);
@@ -258,6 +279,7 @@ class Message {
/**
* Whether the message is editable by the client user.
* @type {boolean}
* @readonly
*/
get editable() {
return this.author.id === this.client.user.id;
@@ -266,6 +288,7 @@ class Message {
/**
* Whether the message is deletable by the client user.
* @type {boolean}
* @readonly
*/
get deletable() {
return this.author.id === this.client.user.id || (this.guild &&
@@ -276,6 +299,7 @@ class Message {
/**
* Whether the message is pinnable by the client user.
* @type {boolean}
* @readonly
*/
get pinnable() {
return !this.guild ||
@@ -301,7 +325,7 @@ class Message {
* // update the content of a message
* message.edit('This is my new content!')
* .then(msg => console.log(`Updated the content of a message from ${msg.author}`))
* .catch(console.log);
* .catch(console.error);
*/
edit(content) {
return this.client.rest.methods.updateMessage(this, content);
@@ -314,8 +338,8 @@ class Message {
* @returns {Promise<Message>}
*/
editCode(lang, content) {
content = this.client.resolver.resolveString(content).replace(/```/g, '`\u200b``');
return this.edit(`\`\`\`${lang ? lang : ''}\n${content}\n\`\`\``);
content = escapeMarkdown(this.client.resolver.resolveString(content), true);
return this.edit(`\`\`\`${lang || ''}\n${content}\n\`\`\``);
}
/**
@@ -342,7 +366,7 @@ class Message {
* // delete a message
* message.delete()
* .then(msg => console.log(`Deleted message from ${msg.author}`))
* .catch(console.log);
* .catch(console.error);
*/
delete(timeout = 0) {
return new Promise((resolve, reject) => {
@@ -363,7 +387,7 @@ class Message {
* // reply to a message
* message.reply('Hey, I'm a reply!')
* .then(msg => console.log(`Sent a reply to ${msg.author}`))
* .catch(console.log);
* .catch(console.error);
*/
reply(content, options = {}) {
content = this.client.resolver.resolveString(content);
@@ -401,8 +425,8 @@ class Message {
if (equal && rawData) {
equal = this.mentions.everyone === message.mentions.everyone &&
this._timestamp === new Date(rawData.timestamp).getTime() &&
this._editedTimestamp === new Date(rawData.edited_timestamp).getTime();
this.createdTimestamp === new Date(rawData.timestamp).getTime() &&
this.editedTimestamp === new Date(rawData.edited_timestamp).getTime();
}
return equal;

View File

@@ -24,6 +24,7 @@ class MessageCollector extends EventEmitter {
* @typedef {Object} CollectorOptions
* @property {number} [time] Duration for the collector in milliseconds
* @property {number} [max] Maximum number of messages to handle
* @property {number} [maxMatches] Maximum number of successfully filtered messages to obtain
*/
/**
@@ -86,12 +87,46 @@ class MessageCollector extends EventEmitter {
* @event MessageCollector#message
*/
this.emit('message', message, this);
if (this.options.max && this.collected.size === this.options.max) this.stop('limit');
if (this.collected.size >= this.options.maxMatches) this.stop('matchesLimit');
else if (this.options.max && this.collected.size === this.options.max) this.stop('limit');
return true;
}
return false;
}
/**
* Returns a promise that resolves when a valid message is sent. Rejects
* with collected messages if the Collector ends before receiving a message.
* @type {Promise<Message>}
* @readonly
*/
get next() {
return new Promise((resolve, reject) => {
if (this.ended) {
reject(this.collected);
return;
}
const cleanup = () => {
this.removeListener('message', onMessage);
this.removeListener('end', onEnd);
};
const onMessage = (...args) => {
cleanup();
resolve(...args);
};
const onEnd = (...args) => {
cleanup();
reject(...args);
};
this.once('message', onMessage);
this.once('end', onEnd);
});
}
/**
* Stops the collector and emits `end`.
* @param {string} [reason='user'] An optional reason for stopping the collector

View File

@@ -0,0 +1,93 @@
/**
* Represents a User's presence
*/
class Presence {
constructor(data) {
if (!data) return;
/**
* The status of the presence:
*
* * **`online`** - user is online
* * **`offline`** - user is offline or invisible
* * **`idle`** - user is AFK
* * **`dnd`** - user is in Do not Disturb
* @type {string}
*/
this.status = data.status || 'offline';
/**
* The game that the user is playing, `null` if they aren't playing a game.
* @type {?Game}
*/
this.game = data.game ? new Game(data.game) : null;
}
update(data) {
this.status = data.status || this.status;
this.game = data.game ? new Game(data.game) : null;
}
/**
* Whether this presence is equal to another
* @param {Presence} other the presence to compare
* @returns {boolean}
*/
equals(other) {
return (
other &&
this.status === other.status &&
this.game ? this.game.equals(other.game) : !other.game
);
}
}
/**
* Represents a Game that is part of a User's presence.
*/
class Game {
constructor(data) {
/**
* The name of the game being played
* @type {string}
*/
this.name = data.name;
/**
* The type of the game status
* @type {number}
*/
this.type = data.type;
/**
* If the game is being streamed, a link to the stream
* @type {?string}
*/
this.url = data.url || null;
}
/**
* Whether or not the game is being streamed
* @type {boolean}
* @readonly
*/
get streaming() {
return this.type === 1;
}
/**
* Whether this game is equal to another game
* @param {Game} other the other game to compare
* @returns {boolean}
*/
equals(other) {
return (
other &&
this.name === other.name &&
this.type === other.type &&
this.url === other.url
);
}
}
exports.Presence = Presence;
exports.Game = Game;

View File

@@ -72,12 +72,21 @@ class Role {
}
/**
* The time the role was created
* The timestamp the role was created at
* @type {number}
* @readonly
* @type {Date}
*/
get creationDate() {
return new Date((this.id / 4194304) + 1420070400000);
get createdTimestamp() {
return (this.id / 4194304) + 1420070400000;
}
/**
* The time the role was created
* @type {Date}
* @readonly
*/
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
@@ -94,6 +103,7 @@ class Role {
/**
* The cached guild members that have this role.
* @type {Collection<string, GuildMember>}
* @readonly
*/
get members() {
return this.guild.members.filter(m => m.roles.has(this.id));
@@ -140,7 +150,17 @@ class Role {
* @returns {boolean}
*/
hasPermissions(permissions, explicit = false) {
return permissions.map(p => this.hasPermission(p, explicit)).every(v => v);
return permissions.every(p => this.hasPermission(p, explicit));
}
/**
* Compares this role's position to another role's.
* @param {Role} role Role to compare to this one
* @returns {number} Negative number if the this role's position is lower (other role's is higher),
* positive number if the this one is higher (other's is lower), 0 if equal
*/
comparePositionTo(role) {
return this.constructor.comparePositions(this, role);
}
/**
@@ -151,7 +171,7 @@ class Role {
* // edit a role
* role.edit({name: 'new role'})
* .then(r => console.log(`Edited role ${r}`))
* .catch(console.log);
* .catch(console.error);
*/
edit(data) {
return this.client.rest.methods.updateGuildRole(this, data);
@@ -165,7 +185,7 @@ class Role {
* // set the name of the role
* role.setName('new role')
* .then(r => console.log(`Edited name of role ${r}`))
* .catch(console.log);
* .catch(console.error);
*/
setName(name) {
return this.client.rest.methods.updateGuildRole(this, { name });
@@ -179,7 +199,7 @@ class Role {
* // set the color of a role
* role.setColor('#FF0000')
* .then(r => console.log(`Set color of role ${r}`))
* .catch(console.log);
* .catch(console.error);
*/
setColor(color) {
return this.client.rest.methods.updateGuildRole(this, { color });
@@ -193,7 +213,7 @@ class Role {
* // set the hoist of the role
* role.setHoist(true)
* .then(r => console.log(`Role hoisted: ${r.hoist}`))
* .catch(console.log);
* .catch(console.error);
*/
setHoist(hoist) {
return this.client.rest.methods.updateGuildRole(this, { hoist });
@@ -207,10 +227,10 @@ class Role {
* // set the position of the role
* role.setPosition(1)
* .then(r => console.log(`Role position: ${r.position}`))
* .catch(console.log);
* .catch(console.error);
*/
setPosition(position) {
return this.client.rest.methods.updateGuildRole(this, { position });
return this.guild.setRolePosition(this, position);
}
/**
@@ -221,12 +241,26 @@ class Role {
* // set the permissions of the role
* role.setPermissions(['KICK_MEMBERS', 'BAN_MEMBERS'])
* .then(r => console.log(`Role updated ${r}`))
* .catch(console.log);
* .catch(console.error);
*/
setPermissions(permissions) {
return this.client.rest.methods.updateGuildRole(this, { permissions });
}
/**
* Set whether this role is mentionable
* @param {boolean} mentionable Whether this role should be mentionable
* @returns {Promise<Role>}
* @example
* // make the role mentionable
* role.setMentionable(true)
* .then(r => console.log(`Role updated ${r}`))
* .catch(console.error);
*/
setMentionable(mentionable) {
return this.client.rest.methods.updateGuildRole(this, { mentionable });
}
/**
* Deletes the role
* @returns {Promise<Role>}
@@ -234,7 +268,7 @@ class Role {
* // delete a role
* role.delete()
* .then(r => console.log(`Deleted role ${r}`))
* .catch(console.log);
* .catch(console.error);
*/
delete() {
return this.client.rest.methods.deleteGuildRole(this);
@@ -265,6 +299,18 @@ class Role {
toString() {
return `<@&${this.id}>`;
}
/**
* Compares the positions of two roles.
* @param {Role} role1 First role to compare
* @param {Role} role2 Second role to compare
* @returns {number} Negative number if the first role's position is lower (second role's is higher),
* positive number if the first's is higher (second's is lower), 0 if equal
*/
static comparePositions(role1, role2) {
if (role1.position === role2.position) return role2.id - role1.id;
return role1.position - role2.position;
}
}
module.exports = Role;

View File

@@ -42,6 +42,38 @@ class TextChannel extends GuildChannel {
return members;
}
/**
* Fetch all webhooks for the channel.
* @returns {Promise<Collection<string, Webhook>>}
*/
fetchWebhooks() {
return this.client.rest.methods.getChannelWebhooks(this);
}
/**
* Create a webhook for the channel.
* @param {string} name The name of the webhook.
* @param {FileResolvable} avatar The avatar for the webhook.
* @returns {Promise<Webhook>} webhook The created webhook.
* @example
* channel.createWebhook('Snek', 'http://snek.s3.amazonaws.com/topSnek.png')
* .then(webhook => console.log(`Created Webhook ${webhook}`))
* .catch(console.log)
*/
createWebhook(name, avatar) {
return new Promise((resolve, reject) => {
if (avatar) {
this.client.resolver.resolveFile(avatar).then(file => {
let base64 = new Buffer(file, 'binary').toString('base64');
let dataURI = `data:;base64,${base64}`;
this.client.rest.methods.createWebhook(this, name, dataURI).then(resolve).catch(reject);
}).catch(reject);
} else {
this.client.rest.methods.createWebhook(this, name).then(resolve).catch(reject);
}
});
}
// These are here only for documentation purposes - they are implemented by TextBasedChannel
sendMessage() { return; }
sendTTSMessage() { return; }

View File

@@ -1,5 +1,6 @@
const TextBasedChannel = require('./interface/TextBasedChannel');
const Constants = require('../util/Constants');
const Presence = require('./Presence').Presence;
/**
* Represents a User on Discord.
@@ -47,45 +48,43 @@ class User {
* @type {boolean}
*/
this.bot = Boolean(data.bot);
/**
* The status of the user:
*
* * **`online`** - user is online
* * **`offline`** - user is offline
* * **`idle`** - user is AFK
* @type {string}
*/
this.status = data.status || this.status || 'offline';
/**
* Represents data about a Game
* @property {string} name the name of the game being played.
* @property {string} [url] the URL of the stream, if the game is being streamed.
* @property {number} [type] if being streamed, this is `1`.
* @typedef {object} Game
*/
/**
* The game that the user is playing, `null` if they aren't playing a game.
* @type {Game}
*/
this.game = data.game;
}
patch(data) {
for (const prop of ['id', 'username', 'discriminator', 'status', 'game', 'avatar', 'bot']) {
for (const prop of ['id', 'username', 'discriminator', 'avatar', 'bot']) {
if (typeof data[prop] !== 'undefined') this[prop] = data[prop];
}
}
/**
* The time the user was created
* The timestamp the user was created at
* @type {number}
* @readonly
* @type {Date}
*/
get creationDate() {
return new Date((this.id / 4194304) + 1420070400000);
get createdTimestamp() {
return (this.id / 4194304) + 1420070400000;
}
/**
* The time the user was created
* @type {Date}
* @readonly
*/
get createdAt() {
return new Date(this.createdTimestamp);
}
/**
* The presence of this user
* @type {Presence}
* @readonly
*/
get presence() {
if (this.client.presences.has(this.id)) return this.client.presences.get(this.id);
for (const guild of this.client.guilds.values()) {
if (guild.presences.has(this.id)) return guild.presences.get(this.id);
}
return new Presence();
}
/**
@@ -136,6 +135,46 @@ class User {
return this.client.rest.methods.deleteChannel(this);
}
/**
* Sends a friend request to the user
* @returns {Promise<User>}
*/
addFriend() {
return this.client.rest.methods.addFriend(this);
}
/**
* Removes the user from your friends
* @returns {Promise<User>}
*/
removeFriend() {
return this.client.rest.methods.removeFriend(this);
}
/**
* Blocks the user
* @returns {Promise<User>}
*/
block() {
return this.client.rest.methods.blockUser(this);
}
/**
* Unblocks the user
* @returns {Promise<User>}
*/
unblock() {
return this.client.rest.methods.unblockUser(this);
}
/**
* Get the profile of the user
* @returns {Promise<UserProfile>}
*/
fetchProfile() {
return this.client.rest.methods.fetchUserProfile(this);
}
/**
* Checks if the user is equal to another. It compares username, ID, discriminator, status and the game being played.
* It is recommended to compare equality by using `user.id === user2.id` unless you want to compare all properties.
@@ -150,16 +189,6 @@ class User {
this.avatar === user.avatar &&
this.bot === Boolean(user.bot);
if (equal) {
if (user.status) equal = this.status === user.status;
if (equal && user.game) {
equal = this.game &&
this.game.name === user.game.name &&
this.game.type === user.game.type &&
this.game.url === user.game.url;
}
}
return equal;
}

View File

@@ -0,0 +1,48 @@
/**
* Represents a User Connection object (or "platform identity")
*/
class UserConnection {
constructor(user, data) {
/**
* The user that owns the Connection
* @type {User}
*/
this.user = user;
this.setup(data);
}
setup(data) {
/**
* The type of the Connection
* @type {string}
*/
this.type = data.type;
/**
* The username of the connection account
* @type {string}
*/
this.name = data.name;
/**
* The id of the connection account
* @type {string}
*/
this.id = data.id;
/**
* Whether the connection is revoked
* @type {Boolean}
*/
this.revoked = data.revoked;
/**
* an array of partial server integrations (not yet implemented in this lib)
* @type {Object[]}
*/
this.integrations = data.integrations;
}
}
module.exports = UserConnection;

View File

@@ -0,0 +1,49 @@
const Collection = require('../util/Collection');
const UserConnection = require('./UserConnection');
/**
* Represents a user's profile on Discord.
*/
class UserProfile {
constructor(user, data) {
/**
* The owner of the profile
* @type {User}
*/
this.user = user;
/**
* The Client that created the instance of the the User.
* @type {Client}
*/
this.client = this.user.client;
Object.defineProperty(this, 'client', { enumerable: false, configurable: false });
/**
* Guilds that the ClientUser and the User share
* @type {Collection<Guild>}
*/
this.mutualGuilds = new Collection();
/**
* The user's connections
* @type {Collection<UserConnection>}
*/
this.connections = new Collection();
this.setup(data);
}
setup(data) {
for (const guild of data.mutual_guilds) {
if (this.client.guilds.has(guild.id)) {
this.mutualGuilds.set(guild.id, this.client.guilds.get(guild.id));
}
}
for (const connection of data.connected_accounts) {
this.connections.set(connection.id, new UserConnection(this.user, connection));
}
}
}
module.exports = UserProfile;

View File

@@ -45,6 +45,22 @@ class VoiceChannel extends GuildChannel {
return null;
}
/**
* Checks if the client has permission join the voice channel
* @type {boolean}
*/
get joinable() {
return this.permissionsFor(this.client.user).hasPermission('CONNECT');
}
/**
* Checks if the client has permission to send audio to the voice channel
* @type {boolean}
*/
get speakable() {
return this.permissionsFor(this.client.user).hasPermission('SPEAK');
}
/**
* Sets the bitrate of the channel
* @param {number} bitrate The new bitrate
@@ -53,7 +69,7 @@ class VoiceChannel extends GuildChannel {
* // set the bitrate of a voice channel
* voiceChannel.setBitrate(48000)
* .then(vc => console.log(`Set bitrate to ${vc.bitrate} for ${vc.name}`))
* .catch(console.log);
* .catch(console.error);
*/
setBitrate(bitrate) {
return this.rest.client.rest.methods.updateChannel(this, { bitrate });
@@ -66,7 +82,7 @@ class VoiceChannel extends GuildChannel {
* // join a voice channel
* voiceChannel.join()
* .then(connection => console.log('Connected!'))
* .catch(console.log);
* .catch(console.error);
*/
join() {
return this.client.voice.joinChannel(this);

205
src/structures/Webhook.js Normal file
View File

@@ -0,0 +1,205 @@
const path = require('path');
const escapeMarkdown = require('../util/EscapeMarkdown');
/**
* Represents a Webhook
*/
class Webhook {
constructor(client, dataOrID, token) {
if (client) {
/**
* The client that instantiated the Channel
* @type {Client}
*/
this.client = client;
Object.defineProperty(this, 'client', { enumerable: false, configurable: false });
if (dataOrID) this.setup(dataOrID);
} else {
this.id = dataOrID;
this.token = token;
this.client = this;
}
}
setup(data) {
/**
* The name of the Webhook
* @type {string}
*/
this.name = data.name;
/**
* The token for the Webhook
* @type {string}
*/
this.token = data.token;
/**
* The avatar for the Webhook
* @type {string}
*/
this.avatar = data.avatar;
/**
* The ID of the Webhook
* @type {string}
*/
this.id = data.id;
/**
* The guild the Webhook belongs to
* @type {string}
*/
this.guildID = data.guild_id;
/**
* The channel the Webhook belongs to
* @type {string}
*/
this.channelID = data.channel_id;
/**
* The owner of the Webhook
* @type {User}
*/
if (data.user) this.owner = data.user;
}
/**
* Options that can be passed into sendMessage, sendTTSMessage, sendFile, sendCode
* @typedef {Object} WebhookMessageOptions
* @property {boolean} [tts=false] Whether or not the message should be spoken aloud
* @property {boolean} [disableEveryone=this.options.disableEveryone] Whether or not @everyone and @here
* should be replaced with plain-text
*/
/**
* Send a message with this webhook
* @param {StringResolvable} content The content to send.
* @param {WebhookMessageOptions} [options={}] The options to provide.
* @returns {Promise<Message|Message[]>}
* @example
* // send a message
* webhook.sendMessage('hello!')
* .then(message => console.log(`Sent message: ${message.content}`))
* .catch(console.error);
*/
sendMessage(content, options = {}) {
return this.client.rest.methods.sendWebhookMessage(this, content, options);
}
/**
* Send a raw slack message with this webhook
* @param {Object} body The raw body to send.
* @returns {Promise}
* @example
* // send a slack message
* webhook.sendSlackMessage({
* 'username': 'Wumpus',
* 'attachments': [{
* 'pretext': 'this looks pretty cool',
* 'color': '#F0F',
* 'footer_icon': 'http://snek.s3.amazonaws.com/topSnek.png',
* 'footer': 'Powered by sneks',
* 'ts': new Date().getTime() / 1000
* }]
* }).catch(console.log);
*/
sendSlackMessage(body) {
return this.client.rest.methods.sendSlackWebhookMessage(this, body);
}
/**
* Send a text-to-speech message with this webhook
* @param {StringResolvable} content The content to send
* @param {WebhookMessageOptions} [options={}] The options to provide
* @returns {Promise<Message|Message[]>}
* @example
* // send a TTS message
* webhook.sendTTSMessage('hello!')
* .then(message => console.log(`Sent tts message: ${message.content}`))
* .catch(console.error);
*/
sendTTSMessage(content, options = {}) {
Object.assign(options, { tts: true });
return this.client.rest.methods.sendWebhookMessage(this, content, options);
}
/**
* Send a file with this webhook
* @param {FileResolvable} attachment The file to send
* @param {string} [fileName="file.jpg"] The name and extension of the file
* @param {StringResolvable} [content] Text message to send with the attachment
* @param {WebhookMessageOptions} [options] The options to provide
* @returns {Promise<Message>}
*/
sendFile(attachment, fileName, content, options = {}) {
if (!fileName) {
if (typeof attachment === 'string') {
fileName = path.basename(attachment);
} else if (attachment && attachment.path) {
fileName = path.basename(attachment.path);
} else {
fileName = 'file.jpg';
}
}
return new Promise((resolve, reject) => {
this.client.resolver.resolveFile(attachment).then(file => {
this.client.rest.methods.sendWebhookMessage(this, content, options, {
file,
name: fileName,
}).then(resolve).catch(reject);
}).catch(reject);
});
}
/**
* Send a code block with this webhook
* @param {string} lang Language for the code block
* @param {StringResolvable} content Content of the code block
* @param {WebhookMessageOptions} options The options to provide
* @returns {Promise<Message|Message[]>}
*/
sendCode(lang, content, options = {}) {
if (options.split) {
if (typeof options.split !== 'object') options.split = {};
if (!options.split.prepend) options.split.prepend = `\`\`\`${lang || ''}\n`;
if (!options.split.append) options.split.append = '\n```';
}
content = escapeMarkdown(this.client.resolver.resolveString(content), true);
return this.sendMessage(`\`\`\`${lang || ''}\n${content}\n\`\`\``, options);
}
/**
* Edit the Webhook.
* @param {string} name The new name for the Webhook
* @param {FileResolvable} avatar The new avatar for the Webhook.
* @returns {Promise<Webhook>}
*/
edit(name = this.name, avatar) {
return new Promise((resolve, reject) => {
if (avatar) {
this.client.resolver.resolveFile(avatar).then(file => {
const dataURI = this.client.resolver.resolveBase64(file);
this.client.rest.methods.editWebhook(this, name, dataURI)
.then(resolve).catch(reject);
}).catch(reject);
} else {
this.client.rest.methods.editWebhook(this, name)
.then(data => {
this.setup(data);
}).catch(reject);
}
});
}
/**
* Delete the Webhook
* @returns {Promise}
*/
delete() {
return this.client.rest.methods.deleteWebhook(this);
}
}
module.exports = Webhook;

View File

@@ -2,6 +2,7 @@ const path = require('path');
const Message = require('../Message');
const MessageCollector = require('../MessageCollector');
const Collection = require('../../util/Collection');
const escapeMarkdown = require('../../util/EscapeMarkdown');
/**
* Interface for classes that have text-channel-like features
@@ -27,7 +28,7 @@ class TextBasedChannel {
* @typedef {Object} MessageOptions
* @property {boolean} [tts=false] Whether or not the message should be spoken aloud
* @property {string} [nonce=''] The nonce for the message
* @property {boolean} [disable_everyone=this.client.options.disable_everyone] Whether or not @everyone and @here
* @property {boolean} [disableEveryone=this.client.options.disableEveryone] Whether or not @everyone and @here
* should be replaced with plain-text
* @property {boolean|SplitOptions} [split=false] Whether or not the message should be split into multiple messages if
* it exceeds the character limit. If an object is provided, these are the options for splitting the message.
@@ -38,8 +39,8 @@ class TextBasedChannel {
* @typedef {Object} SplitOptions
* @property {number} [maxLength=1950] Maximum character length per message piece
* @property {string} [char='\n'] Character to split the message with
* @property {string} [prepend=''] Text to prepend to each middle piece
* @property {string} [append=''] Text to append to each middle piece
* @property {string} [prepend=''] Text to prepend to every piece except the first
* @property {string} [append=''] Text to append to every piece except the last
*/
/**
@@ -51,7 +52,7 @@ class TextBasedChannel {
* // send a message
* channel.sendMessage('hello!')
* .then(message => console.log(`Sent message: ${message.content}`))
* .catch(console.log);
* .catch(console.error);
*/
sendMessage(content, options = {}) {
return this.client.rest.methods.sendMessage(this, content, options);
@@ -66,7 +67,7 @@ class TextBasedChannel {
* // send a TTS message
* channel.sendTTSMessage('hello!')
* .then(message => console.log(`Sent tts message: ${message.content}`))
* .catch(console.log);
* .catch(console.error);
*/
sendTTSMessage(content, options = {}) {
Object.assign(options, { tts: true });
@@ -111,15 +112,16 @@ class TextBasedChannel {
sendCode(lang, content, options = {}) {
if (options.split) {
if (typeof options.split !== 'object') options.split = {};
if (!options.split.prepend) options.split.prepend = `\`\`\`${lang ? lang : ''}\n`;
if (!options.split.prepend) options.split.prepend = `\`\`\`${lang || ''}\n`;
if (!options.split.append) options.split.append = '\n```';
}
content = this.client.resolver.resolveString(content).replace(/```/g, '`\u200b``');
return this.sendMessage(`\`\`\`${lang ? lang : ''}\n${content}\n\`\`\``, options);
content = escapeMarkdown(this.client.resolver.resolveString(content), true);
return this.sendMessage(`\`\`\`${lang || ''}\n${content}\n\`\`\``, options);
}
/**
* Gets a single message from this channel, regardless of it being cached or not.
* <warn>Only OAuth bot accounts can use this method.</warn>
* @param {string} messageID The ID of the message to get
* @returns {Promise<Message>}
* @example
@@ -158,7 +160,7 @@ class TextBasedChannel {
* // get messages
* channel.fetchMessages({limit: 10})
* .then(messages => console.log(`Received ${messages.size} messages`))
* .catch(console.log);
* .catch(console.error);
*/
fetchMessages(options = {}) {
return new Promise((resolve, reject) => {
@@ -241,6 +243,7 @@ class TextBasedChannel {
/**
* Whether or not the typing indicator is being shown in the channel.
* @type {boolean}
* @readonly
*/
get typing() {
return this.client.user._typing.has(this.id);
@@ -249,6 +252,7 @@ class TextBasedChannel {
/**
* Number of times `startTyping` has been called.
* @type {number}
* @readonly
*/
get typingCount() {
if (this.client.user._typing.has(this.id)) return this.client.user._typing.get(this.id).count;
@@ -307,23 +311,28 @@ class TextBasedChannel {
}
/**
* Bulk delete a given Collection or Array of messages in one go. Returns the deleted messages after.
* Bulk delete given messages.
* Only OAuth Bot accounts may use this method.
* @param {Collection<string, Message>|Message[]} messages The messages to delete
* @returns {Collection<string, Message>}
* @param {Collection<string, Message>|Message[]|number} messages Messages to delete, or number of messages to delete
* @returns {Promise<Collection<string, Message>>} Deleted messages
*/
bulkDelete(messages) {
if (messages instanceof Collection) messages = messages.array();
if (!(messages instanceof Array)) return Promise.reject(new TypeError('Messages must be an Array or Collection.'));
const messageIDs = messages.map(m => m.id);
return this.client.rest.methods.bulkDeleteMessages(this, messageIDs);
return new Promise((resolve, reject) => {
if (!isNaN(messages)) {
this.fetchMessages({ limit: messages }).then(msgs => resolve(this.bulkDelete(msgs)));
} else if (messages instanceof Array || messages instanceof Collection) {
const messageIDs = messages instanceof Collection ? messages.keyArray() : messages.map(m => m.id);
resolve(this.client.rest.methods.bulkDeleteMessages(this, messageIDs));
} else {
reject(new TypeError('Messages must be an Array, Collection, or number.'));
}
});
}
_cacheMessage(message) {
const maxSize = this.client.options.max_message_cache;
const maxSize = this.client.options.messageCacheMaxSize;
if (maxSize === 0) return null;
if (this.messages.size >= maxSize) this.messages.delete(this.messages.keys().next().value);
if (this.messages.size >= maxSize && maxSize > 0) this.messages.delete(this.messages.firstKey());
this.messages.set(message.id, message);
return message;
}

View File

@@ -3,26 +3,48 @@
* @extends {Map}
*/
class Collection extends Map {
constructor(iterable) {
super(iterable);
this._array = null;
this._keyArray = null;
}
set(key, val) {
super.set(key, val);
this._array = null;
this._keyArray = null;
}
delete(key) {
super.delete(key);
this._array = null;
this._keyArray = null;
}
/**
* Returns an ordered array of the values of this collection.
* Creates an ordered array of the values of this collection, and caches it internally. The array will only be
* reconstructed if an item is added to or removed from the collection, or if you add/remove elements on the array.
* @returns {Array}
* @example
* // identical to:
* Array.from(collection.values());
*/
array() {
return Array.from(this.values());
if (!this._array || this._array.length !== this.size) this._array = Array.from(this.values());
return this._array;
}
/**
* Returns an ordered array of the keys of this collection.
* Creates an ordered array of the keys of this collection, and caches it internally. The array will only be
* reconstructed if an item is added to or removed from the collection, or if you add/remove elements on the array.
* @returns {Array}
* @example
* // identical to:
* Array.from(collection.keys());
*/
keyArray() {
return Array.from(this.keys());
if (!this._keyArray || this._keyArray.length !== this.size) this._keyArray = Array.from(this.keys());
return this._keyArray;
}
/**
@@ -183,11 +205,27 @@ class Collection extends Map {
*/
filter(fn, thisArg) {
if (thisArg) fn = fn.bind(thisArg);
const collection = new Collection();
const results = new Collection();
for (const [key, val] of this) {
if (fn(val, key, this)) collection.set(key, val);
if (fn(val, key, this)) results.set(key, val);
}
return collection;
return results;
}
/**
* Identical to
* [Array.filter()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/filter).
* @param {function} fn Function used to test (should return a boolean)
* @param {Object} [thisArg] Value to use as `this` when executing function
* @returns {Collection}
*/
filterArray(fn, thisArg) {
if (thisArg) fn = fn.bind(thisArg);
const results = [];
for (const [key, val] of this) {
if (fn(val, key, this)) results.push(val);
}
return results;
}
/**
@@ -248,6 +286,21 @@ class Collection extends Map {
return currentVal;
}
/**
* Combines this collection with others into a new collection. None of the source collections are modified.
* @param {Collection} collections Collections to merge
* @returns {Collection}
* @example const newColl = someColl.concat(someOtherColl, anotherColl, ohBoyAColl);
*/
concat(...collections) {
const newColl = new this.constructor();
for (const [key, val] of this) newColl.set(key, val);
for (const coll of collections) {
for (const [key, val] of coll) newColl.set(key, val);
}
return newColl;
}
/**
* If the items in this collection have a delete method (e.g. messages), invoke
* the delete method. Returns an array of promises

View File

@@ -1,35 +1,44 @@
exports.Package = require('../../package.json');
/**
* Options for a Client.
* @typedef {Object} ClientOptions
* @property {string} [api_request_method='sequential'] 'sequential' or 'burst'. Sequential executes all requests in
* @property {string} [apiRequestMethod='sequential'] 'sequential' or 'burst'. Sequential executes all requests in
* the order they are triggered, whereas burst runs multiple at a time, and doesn't guarantee a particular order.
* @property {number} [shard_id=0] The ID of this shard
* @property {number} [shard_count=0] The number of shards
* @property {number} [max_message_cache=200] Number of messages to cache per channel
* @property {number} [message_cache_lifetime=0] How long until a message should be uncached by the message sweeping
* @property {number} [shardId=0] The ID of this shard
* @property {number} [shardCount=0] The number of shards
* @property {number} [messageCacheMaxSize=200] Maximum number of messages to cache per channel
* @property {boolean} [sync=false] Whether to periodically sync guilds
* (-1 for unlimited - don't do this without message sweeping, otherwise memory usage will climb indefinitely)
* @property {number} [messageCacheLifetime=0] How long until a message should be uncached by the message sweeping
* (in seconds, 0 for forever)
* @property {number} [message_sweep_interval=0] How frequently to remove messages from the cache that are older than
* the max message lifetime (in seconds, 0 for never)
* @property {boolean} [fetch_all_members=false] Whether to cache all guild members and users upon startup
* @property {boolean} [disable_everyone=false] Default value for MessageOptions.disable_everyone
* @property {number} [rest_ws_bridge_timeout=5000] Maximum time permitted between REST responses and their
* @property {number} [messageSweepInterval=0] How frequently to remove messages from the cache that are older than
* the message cache lifetime (in seconds, 0 for never)
* @property {boolean} [fetchAllMembers=false] Whether to cache all guild members and users upon startup, as well as
* upon joining a guild
* @property {boolean} [disableEveryone=false] Default value for MessageOptions.disableEveryone
* @property {number} [restWsBridgeTimeout=5000] Maximum time permitted between REST responses and their
* corresponding websocket events
* @property {string[]} [disabledEvents] An array of disabled websocket events. Events in this array will not be
* processed. Disabling useless events such as 'TYPING_START' can result in significant performance increases on
* large-scale bots.
* @property {WebsocketOptions} [ws] Options for the websocket
*/
exports.DefaultOptions = {
api_request_method: 'sequential',
shard_id: 0,
shard_count: 0,
max_message_cache: 200,
message_cache_lifetime: 0,
message_sweep_interval: 0,
fetch_all_members: false,
disable_everyone: false,
rest_ws_bridge_timeout: 5000,
protocol_version: 6,
apiRequestMethod: 'sequential',
shardId: 0,
shardCount: 0,
messageCacheMaxSize: 200,
messageCacheLifetime: 0,
messageSweepInterval: 0,
fetchAllMembers: false,
disableEveryone: false,
restWsBridgeTimeout: 5000,
disabledEvents: [],
sync: false,
/**
* Websocket options.
* Websocket options. These are left as snake_case to match the API.
* @typedef {Object} WebsocketOptions
* @property {number} [large_threshold=250] Number of members in a guild to be considered large
* @property {boolean} [compress=true] Whether to compress data sent on the connection
@@ -47,23 +56,6 @@ exports.DefaultOptions = {
},
};
exports.Status = {
READY: 0,
CONNECTING: 1,
RECONNECTING: 2,
IDLE: 3,
NEARLY: 4,
};
exports.ChannelTypes = {
text: 0,
DM: 1,
voice: 2,
groupDM: 3,
};
exports.Package = require('../../package.json');
exports.Errors = {
NO_TOKEN: 'Request to use token, but token was unavailable to the client.',
NO_BOT_ACCOUNT: 'You ideally should be using a bot account!',
@@ -72,16 +64,17 @@ exports.Errors = {
NOT_A_PERMISSION: 'Invalid permission string or number.',
INVALID_RATE_LIMIT_METHOD: 'Unknown rate limiting method.',
BAD_LOGIN: 'Incorrect login details were provided.',
INVALID_SHARD: 'Invalid shard settings were provided',
INVALID_SHARD: 'Invalid shard settings were provided.',
};
const API = `https://discordapp.com/api/v${exports.DefaultOptions.protocol_version}`;
const PROTOCOL_VERSION = exports.PROTOCOL_VERSION = 6;
const API = exports.API = `https://discordapp.com/api/v${PROTOCOL_VERSION}`;
const Endpoints = exports.Endpoints = {
// general endpoints
// general
login: `${API}/auth/login`,
logout: `${API}/auth/logout`,
gateway: `${API}/gateway`,
botGateway: `${API}/gateway/bot`,
invite: (id) => `${API}/invite/${id}`,
inviteLink: (id) => `https://discord.gg/${id}`,
CDN: 'https://cdn.discordapp.com',
@@ -89,9 +82,11 @@ const Endpoints = exports.Endpoints = {
// users
user: (userID) => `${API}/users/${userID}`,
userChannels: (userID) => `${Endpoints.user(userID)}/channels`,
userProfile: (userID) => `${Endpoints.user(userID)}/profile`,
avatar: (userID, avatar) => userID === '1' ? avatar : `${Endpoints.user(userID)}/avatars/${avatar}.jpg`,
me: `${API}/users/@me`,
meGuild: (guildID) => `${Endpoints.me}/guilds/${guildID}`,
relationships: (userID) => `${Endpoints.user(userID)}/relationships`,
// guilds
guilds: `${API}/guilds`,
@@ -108,6 +103,7 @@ const Endpoints = exports.Endpoints = {
guildMember: (guildID, memberID) => `${Endpoints.guildMembers(guildID)}/${memberID}`,
stupidInconsistentGuildEndpoint: (guildID) => `${Endpoints.guildMember(guildID, '@me')}/nick`,
guildChannels: (guildID) => `${Endpoints.guild(guildID)}/channels`,
guildEmojis: (guildID) => `${Endpoints.guild(guildID)}/emojis`,
// channels
channels: `${API}/channels`,
@@ -117,6 +113,25 @@ const Endpoints = exports.Endpoints = {
channelTyping: (channelID) => `${Endpoints.channel(channelID)}/typing`,
channelPermissions: (channelID) => `${Endpoints.channel(channelID)}/permissions`,
channelMessage: (channelID, messageID) => `${Endpoints.channelMessages(channelID)}/${messageID}`,
channelWebhooks: (channelID) => `${Endpoints.channel(channelID)}/webhooks`,
// webhooks
webhook: (webhookID, token) => `${API}/webhooks/${webhookID}${token ? `/${token}` : ''}`,
};
exports.Status = {
READY: 0,
CONNECTING: 1,
RECONNECTING: 2,
IDLE: 3,
NEARLY: 4,
};
exports.ChannelTypes = {
text: 0,
DM: 1,
voice: 2,
groupDM: 3,
};
exports.OPCodes = {
@@ -130,6 +145,8 @@ exports.OPCodes = {
RECONNECT: 7,
REQUEST_GUILD_MEMBERS: 8,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
};
exports.VoiceOPCodes = {
@@ -145,52 +162,49 @@ exports.Events = {
READY: 'ready',
GUILD_CREATE: 'guildCreate',
GUILD_DELETE: 'guildDelete',
GUILD_UPDATE: 'guildUpdate',
GUILD_UNAVAILABLE: 'guildUnavailable',
GUILD_AVAILABLE: 'guildAvailable',
GUILD_UPDATE: 'guildUpdate',
GUILD_BAN_ADD: 'guildBanAdd',
GUILD_BAN_REMOVE: 'guildBanRemove',
GUILD_MEMBER_ADD: 'guildMemberAdd',
GUILD_MEMBER_REMOVE: 'guildMemberRemove',
GUILD_MEMBER_UPDATE: 'guildMemberUpdate',
GUILD_ROLE_CREATE: 'guildRoleCreate',
GUILD_ROLE_DELETE: 'guildRoleDelete',
GUILD_ROLE_UPDATE: 'guildRoleUpdate',
GUILD_MEMBER_AVAILABLE: 'guildMemberAvailable',
GUILD_MEMBER_SPEAKING: 'guildMemberSpeaking',
GUILD_MEMBERS_CHUNK: 'guildMembersChunk',
GUILD_ROLE_CREATE: 'roleCreate',
GUILD_ROLE_DELETE: 'roleDelete',
GUILD_ROLE_UPDATE: 'roleUpdate',
GUILD_EMOJI_CREATE: 'guildEmojiCreate',
GUILD_EMOJI_DELETE: 'guildEmojiDelete',
GUILD_EMOJI_UPDATE: 'guildEmojiUpdate',
GUILD_BAN_ADD: 'guildBanAdd',
GUILD_BAN_REMOVE: 'guildBanRemove',
CHANNEL_CREATE: 'channelCreate',
CHANNEL_DELETE: 'channelDelete',
CHANNEL_UPDATE: 'channelUpdate',
PRESENCE_UPDATE: 'presenceUpdate',
USER_UPDATE: 'userUpdate',
VOICE_STATE_UPDATE: 'voiceStateUpdate',
TYPING_START: 'typingStart',
TYPING_STOP: 'typingStop',
WARN: 'warn',
GUILD_MEMBERS_CHUNK: 'guildMembersChunk',
CHANNEL_PINS_UPDATE: 'channelPinsUpdate',
MESSAGE_CREATE: 'message',
MESSAGE_DELETE: 'messageDelete',
MESSAGE_UPDATE: 'messageUpdate',
MESSAGE_BULK_DELETE: 'messageDeleteBulk',
USER_UPDATE: 'userUpdate',
PRESENCE_UPDATE: 'presenceUpdate',
VOICE_STATE_UPDATE: 'voiceStateUpdate',
TYPING_START: 'typingStart',
TYPING_STOP: 'typingStop',
DISCONNECT: 'disconnect',
RECONNECTING: 'reconnecting',
GUILD_MEMBER_SPEAKING: 'guildMemberSpeaking',
MESSAGE_BULK_DELETE: 'messageDeleteBulk',
CHANNEL_PINS_UPDATE: 'channelPinsUpdate',
ERROR: 'error',
WARN: 'warn',
DEBUG: 'debug',
};
exports.WSEvents = {
CHANNEL_CREATE: 'CHANNEL_CREATE',
CHANNEL_DELETE: 'CHANNEL_DELETE',
CHANNEL_UPDATE: 'CHANNEL_UPDATE',
MESSAGE_CREATE: 'MESSAGE_CREATE',
MESSAGE_DELETE: 'MESSAGE_DELETE',
MESSAGE_UPDATE: 'MESSAGE_UPDATE',
PRESENCE_UPDATE: 'PRESENCE_UPDATE',
READY: 'READY',
GUILD_BAN_ADD: 'GUILD_BAN_ADD',
GUILD_BAN_REMOVE: 'GUILD_BAN_REMOVE',
GUILD_SYNC: 'GUILD_SYNC',
GUILD_CREATE: 'GUILD_CREATE',
GUILD_DELETE: 'GUILD_DELETE',
GUILD_UPDATE: 'GUILD_UPDATE',
GUILD_MEMBER_ADD: 'GUILD_MEMBER_ADD',
GUILD_MEMBER_REMOVE: 'GUILD_MEMBER_REMOVE',
GUILD_MEMBER_UPDATE: 'GUILD_MEMBER_UPDATE',
@@ -198,16 +212,35 @@ exports.WSEvents = {
GUILD_ROLE_CREATE: 'GUILD_ROLE_CREATE',
GUILD_ROLE_DELETE: 'GUILD_ROLE_DELETE',
GUILD_ROLE_UPDATE: 'GUILD_ROLE_UPDATE',
GUILD_UPDATE: 'GUILD_UPDATE',
TYPING_START: 'TYPING_START',
GUILD_BAN_ADD: 'GUILD_BAN_ADD',
GUILD_BAN_REMOVE: 'GUILD_BAN_REMOVE',
CHANNEL_CREATE: 'CHANNEL_CREATE',
CHANNEL_DELETE: 'CHANNEL_DELETE',
CHANNEL_UPDATE: 'CHANNEL_UPDATE',
CHANNEL_PINS_UPDATE: 'CHANNEL_PINS_UPDATE',
MESSAGE_CREATE: 'MESSAGE_CREATE',
MESSAGE_DELETE: 'MESSAGE_DELETE',
MESSAGE_UPDATE: 'MESSAGE_UPDATE',
MESSAGE_DELETE_BULK: 'MESSAGE_DELETE_BULK',
USER_UPDATE: 'USER_UPDATE',
PRESENCE_UPDATE: 'PRESENCE_UPDATE',
VOICE_STATE_UPDATE: 'VOICE_STATE_UPDATE',
TYPING_START: 'TYPING_START',
FRIEND_ADD: 'RELATIONSHIP_ADD',
FRIEND_REMOVE: 'RELATIONSHIP_REMOVE',
VOICE_SERVER_UPDATE: 'VOICE_SERVER_UPDATE',
MESSAGE_DELETE_BULK: 'MESSAGE_DELETE_BULK',
CHANNEL_PINS_UPDATE: 'CHANNEL_PINS_UPDATE',
GUILD_SYNC: 'GUILD_SYNC',
RELATIONSHIP_ADD: 'RELATIONSHIP_ADD',
RELATIONSHIP_REMOVE: 'RELATIONSHIP_REMOVE',
};
exports.MessageTypes = {
0: 'DEFAULT',
1: 'RECIPIENT_ADD',
2: 'RECIPIENT_REMOVE',
3: 'CALL',
4: 'CHANNEL_NAME_CHANGE',
5: 'CHANNEL_ICON_CHANGE',
6: 'PINS_ADD',
};
const PermissionFlags = exports.PermissionFlags = {
@@ -238,11 +271,11 @@ const PermissionFlags = exports.PermissionFlags = {
CHANGE_NICKNAME: 1 << 26,
MANAGE_NICKNAMES: 1 << 27,
MANAGE_ROLES_OR_PERMISSIONS: 1 << 28,
MANAGE_WEBHOOKS: 1 << 29,
MANAGE_EMOJIS: 1 << 30,
};
let _ALL_PERMISSIONS = 0;
for (const key in PermissionFlags) _ALL_PERMISSIONS |= PermissionFlags[key];
exports.ALL_PERMISSIONS = _ALL_PERMISSIONS;
exports.DEFAULT_PERMISSIONS = 104324097;

View File

@@ -0,0 +1,5 @@
module.exports = function escapeMarkdown(text, onlyCodeBlock = false, onlyInlineCode = false) {
if (onlyCodeBlock) return text.replace(/```/g, '`\u200b``');
if (onlyInlineCode) return text.replace(/\\(`|\\)/g, '$1').replace(/(`|\\)/g, '\\$1');
return text.replace(/\\(\*|_|`|~|\\)/g, '$1').replace(/(\*|_|`|~|\\)/g, '\\$1');
};

View File

@@ -0,0 +1,19 @@
const superagent = require('superagent');
const botGateway = require('./Constants').Endpoints.botGateway;
/**
* Gets the recommended shard count from Discord
* @param {number} token Discord auth token
* @returns {Promise<number>} the recommended number of shards
*/
module.exports = function fetchRecommendedShards(token) {
return new Promise((resolve, reject) => {
if (!token) throw new Error('A token must be provided.');
superagent.get(botGateway)
.set('Authorization', `Bot ${token.replace(/^Bot\s*/i, '')}`)
.end((err, res) => {
if (err) reject(err);
resolve(res.body.shards);
});
});
};

6
src/util/MakeError.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = function makeError(obj) {
const err = new Error(obj.message);
err.name = obj.name;
err.stack = obj.stack;
return err;
};

View File

@@ -0,0 +1,7 @@
module.exports = function makePlainError(err) {
const obj = {};
obj.name = err.name;
obj.message = err.message;
obj.stack = err.stack;
return obj;
};

View File

@@ -4,29 +4,30 @@ const Discord = require('../');
const request = require('superagent');
const fs = require('fs');
const client = new Discord.Client({ fetch_all_members: false, api_request_method: 'sequential' });
const client = new Discord.Client({ fetchAllMembers: false, apiRequestMethod: 'sequential' });
const { email, password, token } = require('./auth.json');
const { email, password, token, usertoken, song } = require('./auth.json');
client.login(token).then(atoken => console.log('logged in with token ' + atoken)).catch(console.log);
client.login(token).then(atoken => console.log('logged in with token ' + atoken)).catch(console.error);
client.ws.on('send', console.log);
client.on('ready', () => {
console.log('ready!');
});
client.on('userUpdate', (o, n) => {
console.log(o.username, n.username);
});
client.on('guildMemberAdd', (g, m) => console.log(`${m.user.username} joined ${g.name}`));
client.on('channelCreate', channel => {
console.log(`made ${channel.name}`);
});
client.on('guildMemberAdd', (g, m) => {
console.log(`${m.user.username} joined ${g.name}`);
})
client.on('guildMemberUpdate', (g, o, n) => {
console.log(o.nickname, n.nickname);
});
client.on('debug', console.log);
client.on('error', m => console.log('debug', m));
client.on('reconnecting', m => console.log('debug', m));
client.on('message', message => {
if (true) {
@@ -36,6 +37,23 @@ client.on('message', message => {
}
}
if (message.content === 'imma queue pls') {
let count = 0;
let ecount = 0;
for(let x = 0; x < 4000; x++) {
message.channel.sendMessage(`this is message ${x} of 3999`)
.then(m => {
count++;
console.log('reached', count, ecount);
})
.catch(m => {
console.error(m);
ecount++;
console.log('reached', count, ecount);
});
}
}
if (message.content === 'myperms?') {
message.channel.sendMessage('Your permissions are:\n' +
JSON.stringify(message.channel.permissionsFor(message.author).serialize(), null, 4));
@@ -57,7 +75,7 @@ client.on('message', message => {
request
.get('url')
.end((err, res) => {
client.user.setAvatar(res.body).catch(console.log)
client.user.setAvatar(res.body).catch(console.error)
.then(user => message.channel.sendMessage('Done!'));
});
}
@@ -65,11 +83,11 @@ client.on('message', message => {
if (message.content.startsWith('gn')) {
message.guild.setName(message.content.substr(3))
.then(guild => console.log('guild updated to', guild.name))
.catch(console.log);
.catch(console.error);
}
if (message.content === 'leave') {
message.guild.leave().then(guild => console.log('left guild', guild.name)).catch(console.log);
message.guild.leave().then(guild => console.log('left guild', guild.name)).catch(console.error);
}
if (message.content === 'stats') {
@@ -79,7 +97,7 @@ client.on('message', message => {
m += `I am aware of ${client.channels.size} channels overall\n`;
m += `I am aware of ${client.guilds.size} guilds overall\n`;
m += `I am aware of ${client.users.size} users overall\n`;
message.channel.sendMessage(m).then(msg => msg.edit('nah')).catch(console.log);
message.channel.sendMessage(m).then(msg => msg.edit('nah')).catch(console.error);
}
if (message.content === 'messageme!') {
@@ -94,7 +112,7 @@ client.on('message', message => {
message.guild.member(message.mentions[0]).kick().then(member => {
console.log(member);
message.channel.sendMessage('Kicked!' + member.user.username);
}).catch(console.log);
}).catch(console.error);
}
if (message.content === 'ratelimittest') {
@@ -108,17 +126,17 @@ client.on('message', message => {
if (message.content === 'makerole') {
message.guild.createRole().then(role => {
message.channel.sendMessage(`Made role ${role.name}`);
}).catch(console.log);
}).catch(console.error);
}
}
});
function nameLoop(user) {
// user.setUsername(user.username + 'a').then(nameLoop).catch(console.log);
// user.setUsername(user.username + 'a').then(nameLoop).catch(console.error);
}
function chanLoop(channel) {
channel.setName(channel.name + 'a').then(chanLoop).catch(console.log);
channel.setName(channel.name + 'a').then(chanLoop).catch(console.error);
}
client.on('message', msg => {
@@ -142,6 +160,7 @@ let disp, con;
client.on('message', msg => {
if (msg.content.startsWith('/play')) {
console.log('I am now going to play', msg.content);
const chan = msg.content.split(' ').slice(1).join(' ');
con.playStream(ytdl(chan, {filter : 'audioonly'}), { passes : 4 });
}
@@ -151,11 +170,10 @@ client.on('message', msg => {
.then(conn => {
con = conn;
msg.reply('done');
disp = conn.player.playStream(ytdl('https://www.youtube.com/watch?v=oQBiPwklN_Q', {filter : 'audioonly'}), { passes : 3 });
disp = conn.playStream(ytdl(song, {filter:'audioonly'}), { passes : 3 });
conn.player.on('debug', console.log);
conn.player.on('error', err => console.log(123, err));
disp.on('error', err => console.log(123, err));
})
.catch(console.log);
.catch(console.error);
}
})

View File

@@ -2,8 +2,8 @@ const Discord = require('../');
const { token } = require('./auth.json');
const client = new Discord.Client({
shard_id: process.argv[2],
shard_count: process.argv[3],
shardId: process.argv[2],
shardCount: process.argv[3],
});
client.on('message', msg => {
@@ -20,7 +20,12 @@ client.on('message', msg => {
process.send(123);
client.on('ready', () => {
console.log('Ready');
console.log('Ready', client.options.shardId);
if (client.options.shardId === 0)
setTimeout(() => {
console.log('kek dying');
client.destroy();
}, 5000);
});
client.login(token).catch(console.log);
client.login(token).catch(console.error);

View File

@@ -1,6 +1,6 @@
const Discord = require('../');
const sharder = new Discord.ShardingManager(`${process.cwd()}/test/shard.js`);
const sharder = new Discord.ShardingManager(`${process.cwd()}/test/shard.js`, 5, false);
sharder.on('launch', id => console.log(`launched ${id}`));