Merge remote-tracking branch 'origin/indev' into indev-voice

This commit is contained in:
Amish Shah
2016-10-23 14:46:41 +01:00
42 changed files with 1017 additions and 162 deletions

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

@@ -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).

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')}
\`\`\``,
};

File diff suppressed because one or more lines are too long

View File

@@ -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

@@ -20,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.shardId && 'SHARD_ID' in process.env) {
this.options.shardId = Number(process.env.SHARD_ID);
}
if (!this.options.shardCount && 'SHARD_COUNT' in process.env) {
this.options.shardCount = Number(process.env.SHARD_COUNT);
}
this._validateOptions();
/**
* The REST manager of the client
@@ -117,11 +114,15 @@ class Client extends EventEmitter {
*/
this.presences = new Collection();
/**
* The authorization token for the logged in user/bot.
* @type {?string}
*/
this.token = null;
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
@@ -145,7 +146,7 @@ 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();
@@ -170,7 +171,7 @@ class Client extends EventEmitter {
* @readonly
*/
get uptime() {
return this.readyTime ? Date.now() - this.readyTime : null;
return this.readyAt ? Date.now() - this.readyAt : null;
}
/**
@@ -195,6 +196,15 @@ class Client extends EventEmitter {
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.
@@ -241,13 +251,13 @@ class Client extends EventEmitter {
/**
* 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),
});
}
}
@@ -265,13 +275,23 @@ 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.
@@ -281,7 +301,7 @@ class Client extends EventEmitter {
* or -1 if the message cache lifetime is unlimited
*/
sweepMessages(lifetime = this.options.messageCacheLifetime) {
if (typeof lifetime !== 'number' || isNaN(lifetime)) throw new TypeError('Lifetime must be a number.');
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;
@@ -344,6 +364,39 @@ class Client extends EventEmitter {
_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

@@ -118,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,6 +49,7 @@ class ClientManager {
*/
setupKeepAlive(time) {
this.heartbeatInterval = this.client.setInterval(() => {
this.client.emit('debug', 'Sending heartbeat');
this.client.ws.send({
op: Constants.OPCodes.HEARTBEAT,
d: this.client.ws.sequence,

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

@@ -1,13 +1,15 @@
const Action = require('./Action');
const Constants = require('../../util/Constants');
class GuildEmojiUpdateAction extends Action {
handle(data, guild) {
const client = this.client;
for (let emoji of data.emojis) {
const already = guild.emojis.has(emoji.id);
emoji = client.dataManager.newEmoji(emoji, guild);
if (already) client.emit(Constants.Events.GUILD_EMOJI_UPDATE, guild, emoji);
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);
@@ -21,7 +23,7 @@ class GuildEmojiUpdateAction extends Action {
/**
* Emitted whenever an emoji is updated
* @event Client#guildEmojiUpdate
* @param {Guild} guild The guild that the emoji was updated in.
* @param {Emoji} emoji The emoji that was updated.
* @param {Emoji} oldEmoji The old emoji
* @param {Emoji} newEmoji The new emoji
*/
module.exports = GuildEmojiUpdateAction;

View File

@@ -7,13 +7,15 @@ const User = requireStructure('User');
const GuildMember = requireStructure('GuildMember');
const Role = requireStructure('Role');
const Invite = requireStructure('Invite');
const Webhook = requireStructure('Webhook');
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,7 +28,7 @@ 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);
});
@@ -47,21 +49,26 @@ class RESTMethods {
});
}
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 (disableEveryone || (typeof disableEveryone === 'undefined' && this.rest.client.options.disableEveryone)) {
content = content.replace('@everyone', '@\u200beveryone').replace('@here', '@\u200bhere');
}
if (content) {
if (disableEveryone || (typeof disableEveryone === 'undefined' && this.rest.client.options.disableEveryone)) {
content = content.replace('@everyone', '@\u200beveryone').replace('@here', '@\u200bhere');
}
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 +78,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 {
@@ -535,6 +544,138 @@ class RESTMethods {
}).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);
});
}
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);
});
}
}
module.exports = RESTMethods;

View File

@@ -59,6 +59,13 @@ class WebSocketManager extends EventEmitter {
*/
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;
}
@@ -69,9 +76,7 @@ class WebSocketManager extends EventEmitter {
_connect(gateway) {
this.client.emit('debug', `Connecting to gateway ${gateway}`);
this.normalReady = false;
if (this.status !== Constants.Status.RECONNECTING) {
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);
@@ -216,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);
}
@@ -258,9 +263,10 @@ class WebSocketManager extends EventEmitter {
if (unavailableCount === 0) {
this.status = Constants.Status.NEARLY;
if (this.client.options.fetchAllMembers) {
const promises = this.client.guilds.array().map(g => g.fetchMembers());
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() {
@@ -77,6 +79,8 @@ class WebSocketPacketManager {
return false;
}
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();
@@ -84,6 +88,8 @@ class WebSocketPacketManager {
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

@@ -10,12 +10,21 @@ 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);
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);

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

@@ -1,9 +1,14 @@
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'),
getRecommendedShards: require('./util/GetRecommendedShards'),
Channel: require('./structures/Channel'),
ClientUser: require('./structures/ClientUser'),
@@ -28,6 +33,7 @@ module.exports = {
TextChannel: require('./structures/TextChannel'),
User: require('./structures/User'),
VoiceChannel: require('./structures/VoiceChannel'),
Webhook: require('./structures/Webhook'),
version: require('../package').version,
};

View File

@@ -10,8 +10,9 @@ 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 = []) {
/**
* Manager that created the shard
* @type {ShardingManager}
@@ -24,15 +25,22 @@ class Shard {
*/
this.id = id;
/**
* The environment variables for the shard
* @type {Object}
*/
this.env = {
SHARD_ID: this.id,
SHARD_COUNT: this.manager.totalShards,
};
if (this.manager.token) this.env.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', this._handleMessage.bind(this));
this.process.once('exit', () => {

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 getRecommendedShards = require('../util/GetRecommendedShards');
/**
* 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,20 +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.');
/**
* Whether shards should automatically respawn upon exiting
* @type {boolean}
*/
this.respawn = respawn;
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
@@ -60,7 +87,7 @@ class ShardingManager extends EventEmitter {
* @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
@@ -78,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') {
getRecommendedShards(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

@@ -1,4 +1,5 @@
const User = require('./User');
const Collection = require('../util/Collection');
/**
* Represents the logged in client's Discord User
@@ -21,6 +22,20 @@ class ClientUser extends User {
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) {
@@ -118,6 +133,28 @@ class ClientUser extends User {
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);
}
/**
* Set the full presence of the current user.
* @param {Object} data the data to provide
@@ -125,12 +162,12 @@ class ClientUser extends User {
*/
setPresence(data) {
// {"op":3,"d":{"status":"dnd","since":0,"game":null,"afk":false}}
return new Promise((resolve, reject) => {
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) {
if (!game && this.presence.game) {
game = {
name: this.presence.game.name,
type: this.presence.game.type,
@@ -139,10 +176,7 @@ class ClientUser extends User {
}
if (data.status) {
if (typeof data.status !== 'string') {
reject(new TypeError('status must be a string'));
return;
}
if (typeof data.status !== 'string') throw new TypeError('Status must be a string');
status = data.status;
}

View File

@@ -95,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,7 @@ 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));
}
/**

View File

@@ -101,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

@@ -296,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

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
@@ -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';
@@ -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

@@ -248,7 +248,7 @@ 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));
}
/**
@@ -407,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

@@ -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}
@@ -332,8 +339,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\`\`\``);
}
/**

View File

@@ -48,7 +48,7 @@ class MessageAttachment {
* The Proxy URL to this attachment
* @type {string}
*/
this.proxyURL = data.url;
this.proxyURL = data.proxy_url;
/**
* The height of this attachment (if an image)

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,7 +87,8 @@ 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;
@@ -100,9 +102,14 @@ class MessageCollector extends EventEmitter {
*/
get next() {
return new Promise((resolve, reject) => {
if (this.ended) {
reject(this.collected);
return;
}
const cleanup = () => {
this.removeListener(onMessage);
this.removeListener(onEnd);
this.removeListener('message', onMessage);
this.removeListener('end', onEnd);
};
const onMessage = (...args) => {

View File

@@ -150,7 +150,7 @@ 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));
}
/**

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

@@ -135,6 +135,38 @@ 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);
}
/**
* 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.

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

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

@@ -0,0 +1,206 @@
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, 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.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
@@ -111,11 +112,11 @@ 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);
}
/**
@@ -315,17 +316,17 @@ class TextBasedChannel {
* @returns {Collection<string, Message>}
*/
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);
if (!(messages instanceof Array || messages instanceof Collection)) {
return Promise.reject(new TypeError('Messages must be an Array or Collection.'));
}
const messageIDs = messages instanceof Collection ? messages.keyArray() : messages.map(m => m.id);
return this.client.rest.methods.bulkDeleteMessages(this, messageIDs);
}
_cacheMessage(message) {
const maxSize = this.client.options.maxMessageCache;
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;
}
/**

View File

@@ -7,28 +7,33 @@ exports.Package = require('../../package.json');
* the order they are triggered, whereas burst runs multiple at a time, and doesn't guarantee a particular order.
* @property {number} [shardId=0] The ID of this shard
* @property {number} [shardCount=0] The number of shards
* @property {number} [maxMessageCache=200] Number of messages to cache per channel
* @property {number} [messageCacheMaxSize=200] Maximum number of messages to cache per channel
* (-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} [messageSweepInterval=0] How frequently to remove messages from the cache that are older than
* the max message lifetime (in seconds, 0 for never)
* 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 = {
apiRequestMethod: 'sequential',
shardId: 0,
shardCount: 0,
maxMessageCache: 200,
messageCacheMaxSize: 200,
messageCacheLifetime: 0,
messageSweepInterval: 0,
fetchAllMembers: false,
disableEveryone: false,
restWsBridgeTimeout: 5000,
disabledEvents: [],
/**
* Websocket options. These are left as snake_case to match the API.
@@ -67,6 +72,7 @@ const Endpoints = exports.Endpoints = {
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',
@@ -77,6 +83,7 @@ const Endpoints = exports.Endpoints = {
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`,
@@ -103,6 +110,10 @@ 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 = {
@@ -131,6 +142,8 @@ exports.OPCodes = {
RECONNECT: 7,
REQUEST_GUILD_MEMBERS: 8,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
};
exports.VoiceOPCodes = {
@@ -178,6 +191,7 @@ exports.Events = {
TYPING_STOP: 'typingStop',
DISCONNECT: 'disconnect',
RECONNECTING: 'reconnecting',
ERROR: 'error',
WARN: 'warn',
DEBUG: 'debug',
};
@@ -212,6 +226,18 @@ exports.WSEvents = {
FRIEND_ADD: 'RELATIONSHIP_ADD',
FRIEND_REMOVE: 'RELATIONSHIP_REMOVE',
VOICE_SERVER_UPDATE: 'VOICE_SERVER_UPDATE',
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 = {
@@ -242,7 +268,7 @@ 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,
};

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 getRecommendedShards(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);
});
});
};