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

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