Merge master into voice-rewrite

This commit is contained in:
Schuyler Cebulskie
2017-12-02 19:48:43 -05:00
40 changed files with 830 additions and 509 deletions

View File

@@ -127,7 +127,11 @@
"semi-spacing": "error",
"semi": "error",
"space-before-blocks": "error",
"space-before-function-paren": ["error", "never"],
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"space-in-parens": "error",
"space-infix-ops": "error",
"space-unary-ops": "error",

View File

@@ -1,19 +1,19 @@
language: node_js
node_js:
- "8"
- 8
- 9
install: npm install
script: bash ./travis/test.sh
jobs:
include:
- stage: deploy
node_js: 9
script: bash ./travis/deploy.sh
env:
- ENCRYPTION_LABEL="af862fa96d3e"
- COMMIT_AUTHOR_EMAIL="amishshah.2k@gmail.com"
cache:
directories:
- node_modules
install: npm install
jobs:
include:
- stage: test
script: bash ./travis/test.sh
- stage: deploy
script: bash ./travis/deploy.sh
env:
global:
- ENCRYPTION_LABEL: "af862fa96d3e"
- COMMIT_AUTHOR_EMAIL: "amishshah.2k@gmail.com"
dist: trusty
sudo: false

View File

@@ -1,7 +1,7 @@
<div align="center">
<br />
<p>
<a href="https://discord.js.org"><img src="https://discord.js.org/static/logo.svg" width="546" alt="discord.js" /></a>
<a href="https://discord.js.org"><img src="https://discord.js.org/static/logo.svg" width="546" alt="discord.js" id="djs-logo" /></a>
</p>
<br />
<p>

View File

@@ -32,12 +32,11 @@
"homepage": "https://github.com/hydrabolt/discord.js#readme",
"runkitExampleFilename": "./docs/examples/ping.js",
"dependencies": {
"long": "^3.0.0",
"pako": "^1.0.0",
"prism-media": "github:hydrabolt/prism-media#indev",
"snekfetch": "^3.0.0",
"snekfetch": "^3.5.0",
"tweetnacl": "^1.0.0",
"ws": "^3.0.0"
"ws": "^3.3.1"
},
"peerDependencies": {
"bufferutil": "^3.0.0",
@@ -50,12 +49,12 @@
"devDependencies": {
"@types/node": "^8.0.0",
"discord.js-docgen": "hydrabolt/discord.js-docgen",
"eslint": "^4.0.0",
"eslint": "^4.11.0",
"jsdoc-strip-async-await": "^0.1.0",
"json-filter-loader": "^1.0.0",
"parallel-webpack": "^2.0.0",
"parallel-webpack": "^2.2.0",
"uglifyjs-webpack-plugin": "^1.0.0-beta.2",
"webpack": "^3.0.0"
"webpack": "^3.8.0"
},
"engines": {
"node": ">=8.0.0"

View File

@@ -42,6 +42,7 @@ class BaseClient extends EventEmitter {
/**
* API shortcut
* @type {Object}
* @readonly
* @private
*/
get api() {

View File

@@ -163,6 +163,7 @@ class Client extends BaseClient {
/**
* Timestamp of the latest ping's start time
* @type {number}
* @readonly
* @private
*/
get _pingTimestamp() {

View File

@@ -3,7 +3,7 @@ const BaseClient = require('./BaseClient');
/**
* The webhook client.
* @extends {Webhook}
* @implements {Webhook}
* @extends {BaseClient}
*/
class WebhookClient extends BaseClient {

View File

@@ -15,7 +15,7 @@ const libs = {
exports.methods = {};
(async() => {
(async () => {
for (const libName of Object.keys(libs)) {
try {
const lib = require(libName);

View File

@@ -1,6 +1,6 @@
const AbstractHandler = require('./AbstractHandler');
const { Events } = require('../../../../util/Constants');
const ClientUser = require('../../../../structures/ClientUser');
let ClientUser;
class ReadyHandler extends AbstractHandler {
handle(packet) {
@@ -12,6 +12,7 @@ class ReadyHandler extends AbstractHandler {
data.user.user_settings = data.user_settings;
data.user.user_guild_settings = data.user_guild_settings;
if (!ClientUser) ClientUser = require('../../../../structures/ClientUser');
const clientUser = new ClientUser(client, data.user);
client.user = clientUser;
client.readyAt = new Date();

View File

@@ -20,9 +20,13 @@ const Messages = {
SHARDING_REQUIRED: 'This session would have handled too many guilds - Sharding is required.',
SHARDING_CHILD_CONNECTION: 'Failed to send message to shard\'s process.',
SHARDING_PARENT_CONNECTION: 'Failed to send message to master process.',
SHARDING_NO_SHARDS: 'No shards have been spawned',
SHARDING_IN_PROCESS: 'Shards are still being spawned',
SHARDING_ALREADY_SPAWNED: count => `Already spawned ${count} shards`,
SHARDING_NO_SHARDS: 'No shards have been spawned.',
SHARDING_IN_PROCESS: 'Shards are still being spawned.',
SHARDING_ALREADY_SPAWNED: count => `Already spawned ${count} shards.`,
SHARDING_PROCESS_EXISTS: id => `Shard ${id} already has an active process.`,
SHARDING_READY_TIMEOUT: id => `Shard ${id}'s Client took too long to become ready.`,
SHARDING_READY_DISCONNECTED: id => `Shard ${id}'s Client disconnected before becoming ready.`,
SHARDING_READY_DIED: id => `Shard ${id}'s process exited before its Client became ready.`,
COLOR_RANGE: 'Color must be within the range 0 - 16777215 (0xFFFFFF).',
COLOR_CONVERT: 'Unable to convert color to a number.',

View File

@@ -18,6 +18,7 @@ module.exports = {
Permissions: require('./util/Permissions'),
Snowflake: require('./util/Snowflake'),
SnowflakeUtil: require('./util/Snowflake'),
Structures: require('./util/Structures'),
Util: Util,
util: Util,
version: require('../package.json').version,
@@ -29,14 +30,18 @@ module.exports = {
GuildChannelStore: require('./stores/GuildChannelStore'),
GuildMemberStore: require('./stores/GuildMemberStore'),
GuildStore: require('./stores/GuildStore'),
ReactionUserStore: require('./stores/ReactionUserStore'),
MessageStore: require('./stores/MessageStore'),
PresenceStore: require('./stores/PresenceStore'),
RoleStore: require('./stores/RoleStore'),
UserStore: require('./stores/UserStore'),
// Shortcuts to Util methods
discordSort: Util.discordSort,
escapeMarkdown: Util.escapeMarkdown,
fetchRecommendedShards: Util.fetchRecommendedShards,
resolveColor: Util.resolveColor,
resolveString: Util.resolveString,
splitMessage: Util.splitMessage,
// Structures
@@ -45,7 +50,10 @@ module.exports = {
CategoryChannel: require('./structures/CategoryChannel'),
Channel: require('./structures/Channel'),
ClientApplication: require('./structures/ClientApplication'),
ClientUser: require('./structures/ClientUser'),
get ClientUser() {
// This is a getter so that it properly extends any custom User class
return require('./structures/ClientUser');
},
ClientUserChannelOverride: require('./structures/ClientUserChannelOverride'),
ClientUserGuildSettings: require('./structures/ClientUserGuildSettings'),
ClientUserSettings: require('./structures/ClientUserSettings'),

View File

@@ -39,12 +39,13 @@ class RequestHandler {
/**
* Emitted when the client hits a rate limit while making a request
* @event Client#rateLimit
* @prop {number} timeout Timeout in ms
* @prop {number} limit Number of requests that can be made to this endpoint
* @prop {number} timeDifference Delta-T in ms between your system and Discord servers
* @prop {string} method HTTP method used for request that triggered this event
* @prop {string} path Path used for request that triggered this event
* @prop {string} route Route used for request that triggered this event
* @param {Object} rateLimitInfo Object containing the rate limit info
* @param {number} rateLimitInfo.timeout Timeout in ms
* @param {number} rateLimitInfo.limit Number of requests that can be made to this endpoint
* @param {number} rateLimitInfo.timeDifference Delta-T in ms between your system and Discord servers
* @param {string} rateLimitInfo.method HTTP method used for request that triggered this event
* @param {string} rateLimitInfo.path Path used for request that triggered this event
* @param {string} rateLimitInfo.route Route used for request that triggered this event
*/
this.client.emit(RATE_LIMIT, {
timeout,

View File

@@ -1,18 +1,24 @@
const childProcess = require('child_process');
const EventEmitter = require('events');
const path = require('path');
const Util = require('../util/Util');
const { Error } = require('../errors');
/**
* Represents a Shard spawned by the ShardingManager.
* A self-contained shard created by the {@link ShardingManager}. Each one has a {@link ChildProcess} that contains
* an instance of the bot and its {@link Client}. When its child process exits for any reason, the shard will spawn a
* new one to replace it as necessary.
* @extends EventEmitter
*/
class Shard {
class Shard extends EventEmitter {
/**
* @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
* @param {ShardingManager} manager Manager that is spawning this shard
* @param {number} id ID of this shard
* @param {string[]} [args=[]] Command line arguments to pass to the script
*/
constructor(manager, id, args = []) {
super();
/**
* Manager that created the shard
* @type {ShardingManager}
@@ -26,7 +32,13 @@ class Shard {
this.id = id;
/**
* The environment variables for the shard
* Arguments for the shard's process
* @type {string[]}
*/
this.args = args;
/**
* Environment variables for the shard's process
* @type {Object}
*/
this.env = Object.assign({}, process.env, {
@@ -36,19 +48,81 @@ class Shard {
});
/**
* Process of the shard
* @type {ChildProcess}
* Whether the shard's {@link Client} is ready
* @type {boolean}
*/
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', () => {
if (this.manager.respawn) this.manager.createShard(this.id);
});
this.ready = false;
/**
* Process of the shard
* @type {?ChildProcess}
*/
this.process = null;
/**
* Ongoing promises for calls to {@link Shard#eval}, mapped by the `script` they were called with
* @type {Map<string, Promise>}
* @private
*/
this._evals = new Map();
/**
* Ongoing promises for calls to {@link Shard#fetchClientValue}, mapped by the `prop` they were called with
* @type {Map<string, Promise>}
* @private
*/
this._fetches = new Map();
/**
* Listener function for the {@link ChildProcess}' `exit` event
* @type {Function}
* @private
*/
this._exitListener = this._handleExit.bind(this, undefined);
}
/**
* Forks a child process for the shard.
* <warn>You should not need to call this manually.</warn>
* @param {boolean} [waitForReady=true] Whether to wait until the {@link Client} has become ready before resolving
* @returns {Promise<ChildProcess>}
*/
async spawn(waitForReady = true) {
if (this.process) throw new Error('SHARDING_PROCESS_EXISTS', this.id);
this.process = childProcess.fork(path.resolve(this.manager.file), this.args, { env: this.env })
.on('message', this._handleMessage.bind(this))
.on('exit', this._exitListener);
/**
* Emitted upon the creation of the shard's child process.
* @event Shard#spawn
* @param {ChildProcess} process Child process that was created
*/
this.emit('spawn', this.process);
if (!waitForReady) return this.process;
await new Promise((resolve, reject) => {
this.once('ready', resolve);
this.once('disconnect', () => reject(new Error('SHARDING_READY_DISCONNECTED', this.id)));
this.once('death', () => reject(new Error('SHARDING_READY_DIED', this.id)));
setTimeout(() => reject(new Error('SHARDING_READY_TIMEOUT', this.id)), 30000);
});
return this.process;
}
/**
* Kills and restarts the shard's process.
* @param {number} [delay=500] How long to wait between killing the process and restarting it (in milliseconds)
* @param {boolean} [waitForReady=true] Whether to wait the {@link Client} has become ready before resolving
* @returns {Promise<ChildProcess>}
*/
async respawn(delay = 500, waitForReady = true) {
this.process.removeListener('exit', this._exitListener);
this.process.kill();
this._handleExit(false);
if (delay > 0) await Util.delayFor(delay);
return this.spawn(waitForReady);
}
/**
@@ -100,7 +174,7 @@ class Shard {
}
/**
* Evaluates a script on the shard, in the context of the client.
* Evaluates a script on the shard, in the context of the {@link Client}.
* @param {string} script JavaScript to run on the shard
* @returns {Promise<*>} Result of the script execution
*/
@@ -134,6 +208,39 @@ class Shard {
*/
_handleMessage(message) {
if (message) {
// Shard is ready
if (message._ready) {
this.ready = true;
/**
* Emitted upon the shard's {@link Client#ready} event.
* @event Shard#ready
*/
this.emit('ready');
return;
}
// Shard has disconnected
if (message._disconnect) {
this.ready = false;
/**
* Emitted upon the shard's {@link Client#disconnect} event.
* @event Shard#disconnect
*/
this.emit('disconnect');
return;
}
// Shard is attempting to reconnect
if (message._reconnecting) {
this.ready = false;
/**
* Emitted upon the shard's {@link Client#reconnecting} event.
* @event Shard#reconnecting
*/
this.emit('reconnecting');
return;
}
// Shard is requesting a property fetch
if (message._sFetchProp) {
this.manager.fetchClientValues(message._sFetchProp).then(
@@ -151,15 +258,44 @@ class Shard {
);
return;
}
// Shard is requesting a respawn of all shards
if (message._sRespawnAll) {
const { shardDelay, respawnDelay, waitForReady } = message._sRespawnAll;
this.manager.respawnAll(shardDelay, respawnDelay, waitForReady).catch(() => {
// Do nothing
});
return;
}
}
/**
* Emitted upon recieving a message from a shard.
* @event ShardingManager#message
* @param {Shard} shard Shard that sent the message
* Emitted upon recieving a message from the child process.
* @event Shard#message
* @param {*} message Message that was received
*/
this.manager.emit('message', this, message);
this.emit('message', message);
}
/**
* Handles the shard's process exiting.
* @param {boolean} [respawn=this.manager.respawn] Whether to spawn the shard again
* @private
*/
_handleExit(respawn = this.manager.respawn) {
/**
* Emitted upon the shard's child process exiting.
* @event Shard#death
* @param {ChildProcess} process Child process that exited
*/
this.emit('death', this.process);
this.ready = false;
this.process = null;
this._evals.clear();
this._fetches.clear();
if (respawn) this.spawn().catch(err => this.emit('error', err));
}
}

View File

@@ -3,15 +3,19 @@ const { Events } = require('../util/Constants');
const { Error } = require('../errors');
/**
* Helper class for sharded clients spawned as a child process, such as from a ShardingManager.
* Helper class for sharded clients spawned as a child process, such as from a {@link ShardingManager}.
* Utilises IPC to send and receive data to/from the master process and other shards.
*/
class ShardClientUtil {
/**
* @param {Client} client The client of the current shard
* @param {Client} client Client of the current shard
*/
constructor(client) {
this.client = client;
process.on('message', this._handleMessage.bind(this));
client.on('ready', () => { process.send({ _ready: true }); });
client.on('disconnect', () => { process.send({ _disconnect: true }); });
client.on('reconnecting', () => { process.send({ _reconnecting: true }); });
}
/**
@@ -49,13 +53,14 @@ class ShardClientUtil {
/**
* 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>}
* @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);
* @see {@link ShardingManager#fetchClientValues}
*/
fetchClientValues(prop) {
return new Promise((resolve, reject) => {
@@ -74,9 +79,10 @@ class ShardClientUtil {
}
/**
* Evaluates a script on all shards, in the context of the Clients.
* Evaluates a script on all shards, in the context of the {@link Clients}.
* @param {string} script JavaScript to run on each shard
* @returns {Promise<Array>} Results of the script execution
* @returns {Promise<Array<*>>} Results of the script execution
* @see {@link ShardingManager#broadcastEval}
*/
broadcastEval(script) {
return new Promise((resolve, reject) => {
@@ -94,6 +100,19 @@ class ShardClientUtil {
});
}
/**
* Requests a respawn of all shards.
* @param {number} [shardDelay=5000] How long to wait between shards (in milliseconds)
* @param {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it
* (in milliseconds)
* @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another
* @returns {Promise<void>} Resolves upon the message being sent
* @see {@link ShardingManager#respawnAll}
*/
respawnAll(shardDelay = 5000, respawnDelay = 500, waitForReady = true) {
return this.send({ _sRespawnAll: { shardDelay, respawnDelay, waitForReady } });
}
/**
* Handles an IPC message.
* @param {*} message Message received

View File

@@ -7,9 +7,12 @@ const Util = require('../util/Util');
const { Error, TypeError, RangeError } = require('../errors');
/**
* 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.
* This is a utility class that makes multi-process sharding of a bot an easy and painless experience.
* It works by spawning a self-contained {@link ChildProcess} for each individual shard, each containing its own
* instance of your bot's {@link Client}. They all have a line of communication with the master process, and there are
* several useful methods that utilise it in order to simplify tasks that are normally difficult with sharding. It can
* spawn a specific number of shards or the amount that Discord suggests for the bot, and takes a path to your main bot
* script to launch for each one.
* @extends {EventEmitter}
*/
class ShardingManager extends EventEmitter {
@@ -82,33 +85,33 @@ class ShardingManager extends EventEmitter {
/**
* Spawns a single shard.
* @param {number} id The ID of the shard to spawn. **This is usually not necessary**
* @returns {Promise<Shard>}
* @param {number} [id=this.shards.size] ID of the shard to spawn -
* **This is usually not necessary to manually specify.**
* @returns {Shard}
*/
createShard(id = this.shards.size) {
const shard = new Shard(this, id, this.shardArgs);
this.shards.set(id, shard);
/**
* Emitted upon launching a shard.
* @event ShardingManager#launch
* @param {Shard} shard Shard that was launched
* Emitted upon creating a shard.
* @event ShardingManager#shardCreate
* @param {Shard} shard Shard that was created
*/
this.emit('launch', shard);
return Promise.resolve(shard);
this.emit('shardCreate', shard);
return shard;
}
/**
* Spawns multiple shards.
* @param {number} [amount=this.totalShards] Number of shards to spawn
* @param {number} [delay=7500] How long to wait in between spawning each shard (in milliseconds)
* @param {number} [delay=5500] How long to wait in between spawning each shard (in milliseconds)
* @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another
* @returns {Promise<Collection<number, Shard>>}
*/
spawn(amount = this.totalShards, delay = 7500) {
async spawn(amount = this.totalShards, delay = 5500, waitForReady = true) {
// Obtain/verify the number of shards to spawn
if (amount === 'auto') {
return Util.fetchRecommendedShards(this.token).then(count => {
this.totalShards = count;
return this._spawn(count, delay);
});
amount = await Util.fetchRecommendedShards(this.token);
} else {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'a number.');
@@ -117,41 +120,22 @@ class ShardingManager extends EventEmitter {
if (amount !== Math.floor(amount)) {
throw new TypeError('CLIENT_INVALID_OPTION', 'Amount of shards', 'an integer.');
}
return 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('SHARDING_ALREADY_SPAWNED', this.shards.size);
this.totalShards = amount;
// Make sure this many shards haven't already been spawned
if (this.shards.size >= amount) throw new Error('SHARDING_ALREADY_SPAWNED', this.shards.size);
this.totalShards = amount;
this.createShard();
if (this.shards.size >= this.totalShards) {
resolve(this.shards);
return;
}
// Spawn the shards
for (let s = 1; s <= amount; s++) {
const promises = [];
const shard = this.createShard();
promises.push(shard.spawn(waitForReady));
if (delay > 0 && s !== amount) promises.push(Util.delayFor(delay));
await Promise.all(promises); // eslint-disable-line no-await-in-loop
}
if (delay <= 0) {
while (this.shards.size < this.totalShards) this.createShard();
resolve(this.shards);
} else {
const interval = setInterval(() => {
this.createShard();
if (this.shards.size >= this.totalShards) {
clearInterval(interval);
resolve(this.shards);
}
}, delay);
}
});
return this.shards;
}
/**
@@ -166,9 +150,9 @@ class ShardingManager extends EventEmitter {
}
/**
* Evaluates a script on all shards, in the context of the Clients.
* Evaluates a script on all shards, in the context of the {@link Client}s.
* @param {string} script JavaScript to run on each shard
* @returns {Promise<Array>} Results of the script execution
* @returns {Promise<Array<*>>} Results of the script execution
*/
broadcastEval(script) {
const promises = [];
@@ -179,7 +163,7 @@ class ShardingManager extends EventEmitter {
/**
* 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>}
* @returns {Promise<Array<*>>}
* @example
* manager.fetchClientValues('guilds.size')
* .then(results => {
@@ -194,6 +178,24 @@ class ShardingManager extends EventEmitter {
for (const shard of this.shards.values()) promises.push(shard.fetchClientValue(prop));
return Promise.all(promises);
}
/**
* Kills all running shards and respawns them.
* @param {number} [shardDelay=5000] How long to wait between shards (in milliseconds)
* @param {number} [respawnDelay=500] How long to wait between killing a shard's process and restarting it
* (in milliseconds)
* @param {boolean} [waitForReady=true] Whether to wait for a shard to become ready before continuing to another
* @returns {Promise<Collection<string, Shard>>}
*/
async respawnAll(shardDelay = 5000, respawnDelay = 500, waitForReady = true) {
let s = 0;
for (const shard of this.shards) {
const promises = [shard.respawn(respawnDelay, waitForReady)];
if (++s < this.shards.size && shardDelay > 0) promises.push(Util.delayFor(shardDelay));
await Promise.all(promises); // eslint-disable-line no-await-in-loop
}
return this.shards;
}
}
module.exports = ShardingManager;

View File

@@ -1,4 +1,5 @@
const Collection = require('../util/Collection');
let Structures;
/**
* Manages the creation, retrieval and deletion of a specific data model.
@@ -7,8 +8,9 @@ const Collection = require('../util/Collection');
class DataStore extends Collection {
constructor(client, iterable, holds) {
super();
if (!Structures) Structures = require('../util/Structures');
Object.defineProperty(this, 'client', { value: client });
Object.defineProperty(this, 'holds', { value: holds });
Object.defineProperty(this, 'holds', { value: Structures.get(holds.name) || holds });
if (iterable) for (const item of iterable) this.create(item);
}

View File

@@ -0,0 +1,33 @@
const DataStore = require('./DataStore');
/**
* A data store to store User models who reacted to a MessageReaction.
* @extends {DataStore}
*/
class ReactionUserStore extends DataStore {
constructor(client, iterable, reaction) {
super(client, iterable, require('../structures/User'));
this.reaction = reaction;
}
/**
* Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their IDs.
* @param {Object} [options] Options for fetching the users
* @param {number} [options.limit=100] The maximum amount of users to fetch, defaults to 100
* @param {Snowflake} [options.before] Limit fetching users to those with an id lower than the supplied id
* @param {Snowflake} [options.after] Limit fetching users to those with an id greater than the supplied id
* @returns {Promise<ReactionUserStore<Snowflake, User>>}
*/
async fetch({ limit = 100, after, before } = {}) {
const message = this.reaction.message;
const users = await this.client.api.channels[message.channel.id].messages[message.id]
.reactions[this.reaction.emoji.identifier]
.get({ query: { limit, before, after } });
for (const rawUser of users) {
const user = this.client.users.create(rawUser);
this.set(user.id, user);
}
return this;
}
}
module.exports = ReactionUserStore;

View File

@@ -6,7 +6,7 @@ const GuildChannel = require('./GuildChannel');
*/
class CategoryChannel extends GuildChannel {
/**
* The channels that are part of this category
* Channels that are part of this category
* @type {?Collection<Snowflake, GuildChannel>}
* @readonly
*/

View File

@@ -66,32 +66,37 @@ class Channel extends Base {
}
static create(client, data, guild) {
const DMChannel = require('./DMChannel');
const GroupDMChannel = require('./GroupDMChannel');
const TextChannel = require('./TextChannel');
const VoiceChannel = require('./VoiceChannel');
const CategoryChannel = require('./CategoryChannel');
const GuildChannel = require('./GuildChannel');
const Structures = require('../util/Structures');
let channel;
if (data.type === ChannelTypes.DM) {
const DMChannel = Structures.get('DMChannel');
channel = new DMChannel(client, data);
} else if (data.type === ChannelTypes.GROUP) {
const GroupDMChannel = Structures.get('GroupDMChannel');
channel = new GroupDMChannel(client, data);
} else {
guild = guild || client.guilds.get(data.guild_id);
if (guild) {
switch (data.type) {
case ChannelTypes.TEXT:
case ChannelTypes.TEXT: {
const TextChannel = Structures.get('TextChannel');
channel = new TextChannel(guild, data);
break;
case ChannelTypes.VOICE:
}
case ChannelTypes.VOICE: {
const VoiceChannel = Structures.get('VoiceChannel');
channel = new VoiceChannel(guild, data);
break;
case ChannelTypes.CATEGORY:
}
case ChannelTypes.CATEGORY: {
const CategoryChannel = Structures.get('CategoryChannel');
channel = new CategoryChannel(guild, data);
break;
default:
}
default: {
const GuildChannel = Structures.get('GuildChannel');
channel = new GuildChannel(guild, data);
}
}
guild.channels.set(channel.id, channel);
}

View File

@@ -150,11 +150,12 @@ class ClientApplication extends Base {
* @returns {Promise<Object>}
*/
fetchAssets() {
return this.client.api.applications(this.id).assets.get()
const types = Object.keys(ClientApplicationAssetTypes);
return this.client.api.oauth2.applications(this.id).assets.get()
.then(assets => assets.map(a => ({
id: a.id,
name: a.name,
type: Object.keys(ClientApplicationAssetTypes)[a.type - 1],
type: types[a.type - 1],
})));
}
@@ -167,7 +168,7 @@ class ClientApplication extends Base {
*/
createAsset(name, data, type) {
return DataResolver.resolveBase64(data).then(b64 =>
this.client.api.applications(this.id).assets.post({ data: {
this.client.api.oauth2.applications(this.id).assets.post({ data: {
name,
data: b64,
type: ClientApplicationAssetTypes[type.toUpperCase()],

View File

@@ -1,4 +1,4 @@
const User = require('./User');
const Structures = require('../util/Structures');
const Collection = require('../util/Collection');
const ClientUserSettings = require('./ClientUserSettings');
const ClientUserGuildSettings = require('./ClientUserGuildSettings');
@@ -11,7 +11,7 @@ const Guild = require('./Guild');
* Represents the logged in client's Discord user.
* @extends {User}
*/
class ClientUser extends User {
class ClientUser extends Structures.get('User') {
_patch(data) {
super._patch(data);
@@ -248,7 +248,7 @@ class ClientUser extends User {
/**
* Fetches messages that mentioned the client's user.
* <warn>This is only available when using a user account.</warn>
* @param {Object} [options] Options for the fetch
* @param {Object} [options={}] Options for the fetch
* @param {number} [options.limit=25] Maximum number of mentions to retrieve
* @param {boolean} [options.roles=true] Whether to include role mentions
* @param {boolean} [options.everyone=true] Whether to include everyone/here mentions

View File

@@ -114,8 +114,18 @@ class Guild extends Base {
this.large = Boolean('large' in data ? data.large : this.large);
/**
* An array of guild features
* @type {string[]}
* An array of enabled guild features, here are the possible values:
* * INVITE_SPLASH
* * MORE_EMOJI
* * VERIFIED
* * VIP_REGIONS
* * VANITY_URL
* @typedef {string} Features
*/
/**
* An array of guild features partnered guilds have enabled
* @type {Features[]}
*/
this.features = data.features;
@@ -311,7 +321,7 @@ class Guild extends Base {
/**
* System channel for this guild
* @type {?GuildChannel}
* @type {?TextChannel}
* @readonly
*/
get systemChannel() {
@@ -431,10 +441,16 @@ class Guild extends Base {
return this.members.resolve(user);
}
/**
* An object containing information about a guild member's ban.
* @typedef {Object} BanInfo
* @property {User} user User that was banned
* @property {?string} reason Reason the user was banned
*/
/**
* Fetches a collection of banned users in this guild.
* The returned collection contains user objects keyed under `user` and reasons keyed under `reason`.
* @returns {Promise<Collection<Snowflake, Object>>}
* @returns {Promise<Collection<Snowflake, BanInfo>>}
*/
fetchBans() {
return this.client.api.guilds(this.id).bans.get().then(bans =>
@@ -496,7 +512,7 @@ class Guild extends Base {
* @param {Snowflake|GuildAuditLogsEntry} [options.after] Limit to entries from after specified entry
* @param {number} [options.limit] Limit number of entries
* @param {UserResolvable} [options.user] Only show entries involving this user
* @param {ActionType|number} [options.type] Only show entries involving this action type
* @param {AuditLogAction|number} [options.type] Only show entries involving this action type
* @returns {Promise<GuildAuditLogs>}
*/
fetchAuditLogs(options = {}) {
@@ -528,7 +544,9 @@ class Guild extends Base {
* @returns {Promise<GuildMember>}
*/
addMember(user, options) {
if (this.members.has(user.id)) return Promise.resolve(this.members.get(user.id));
user = this.client.users.resolveID(user);
if (!user) return Promise.reject(new TypeError('INVALID_TYPE', 'user', 'UserResolvable'));
if (this.members.has(user)) return Promise.resolve(this.members.get(user));
options.access_token = options.accessToken;
if (options.roles) {
const roles = [];
@@ -541,7 +559,7 @@ class Guild extends Base {
roles.push(role.id);
}
}
return this.client.api.guilds(this.id).members(user.id).put({ data: options })
return this.client.api.guilds(this.id).members(user).put({ data: options })
.then(data => this.members.create(data));
}
@@ -794,6 +812,7 @@ class Guild extends Base {
* @returns {Promise<Guild>}
*/
allowDMs(allow) {
if (this.client.user.bot) return Promise.reject(new Error('FEATURE_USER_ONLY'));
const settings = this.client.user.settings;
if (allow) return settings.removeRestrictedGuild(this);
else return settings.addRestrictedGuild(this);
@@ -802,11 +821,10 @@ class Guild extends Base {
/**
* Bans a user from the guild.
* @param {UserResolvable} user The user to ban
* @param {Object} [options] Ban options. If a number, the number of days to delete messages for, if a
* string, the ban reason. Supplying an object allows you to do both.
* @param {Object} [options] Options for the ban
* @param {number} [options.days=0] Number of days of messages to delete
* @param {string} [options.reason] Reason for banning
* @returns {Promise<GuildMember|User|string>} Result object will be resolved as specifically as possible.
* @returns {Promise<GuildMember|User|Snowflake>} Result object will be resolved as specifically as possible.
* If the GuildMember cannot be resolved, the User will instead be attempted to be resolved. If that also cannot
* be resolved, the user ID will be the result.
* @example
@@ -1054,8 +1072,7 @@ class Guild extends Base {
.then(emoji => this.client.actions.GuildEmojiCreate.handle(this, emoji).emoji);
}
return DataResolver.resolveImage(attachment)
.then(image => this.createEmoji(image, name, { roles, reason }));
return DataResolver.resolveImage(attachment).then(image => this.createEmoji(image, name, { roles, reason }));
}
/**
@@ -1133,6 +1150,34 @@ class Guild extends Base {
return this.name;
}
/**
* Creates a collection of this guild's roles, sorted by their position and IDs.
* @returns {Collection<Role>}
* @private
*/
_sortedRoles() {
return Util.discordSort(this.roles);
}
/**
* Creates a collection of this guild's or a specific category's channels, sorted by their position and IDs.
* @param {GuildChannel} [channel] Category to get the channels of
* @returns {Collection<GuildChannel>}
* @private
*/
_sortedChannels(channel) {
const category = channel.type === ChannelTypes.CATEGORY;
return Util.discordSort(this.channels.filter(c =>
c.type === channel.type && (category || c.parent === channel.parent)
));
}
/**
* Handles a user speaking update in a voice channel.
* @param {Snowflake} user ID of the user that the update is for
* @param {boolean} speaking Whether the user is speaking
* @private
*/
_memberSpeakUpdate(user, speaking) {
const member = this.members.get(user);
if (member && member.speaking !== speaking) {
@@ -1146,23 +1191,15 @@ class Guild extends Base {
this.client.emit(Events.GUILD_MEMBER_SPEAKING, member, speaking);
}
}
_sortedRoles() {
return Util.discordSort(this.roles);
}
_sortedChannels(channel) {
const category = channel.type === ChannelTypes.CATEGORY;
return Util.discordSort(this.channels.filter(c =>
c.type === channel.type && (category || c.parent === channel.parent)));
}
}
// TODO: Document this thing
class VoiceStateCollection extends Collection {
constructor(guild) {
super();
this.guild = guild;
}
set(id, voiceState) {
const member = this.guild.members.get(id);
if (member) {

View File

@@ -293,7 +293,7 @@ class GuildChannel extends Channel {
topic: data.topic,
nsfw: data.nsfw,
bitrate: data.bitrate || (this.bitrate ? this.bitrate * 1000 : undefined),
user_limit: data.userLimit != null ? data.userLimit : this.userLimit, // eslint-disable-line eqeqeq
user_limit: typeof data.userLimit !== 'undefined' ? data.userLimit : this.userLimit,
parent_id: data.parentID,
lock_permissions: data.lockPermissions,
permission_overwrites: data.permissionOverwrites,

View File

@@ -77,36 +77,42 @@ class GuildMember extends Base {
/**
* Whether this member is deafened server-wide
* @type {boolean}
* @readonly
*/
get serverDeaf() { return this.voiceState.deaf; }
/**
* Whether this member is muted server-wide
* @type {boolean}
* @readonly
*/
get serverMute() { return this.voiceState.mute; }
/**
* Whether this member is self-muted
* @type {boolean}
* @readonly
*/
get selfMute() { return this.voiceState.self_mute; }
/**
* Whether this member is self-deafened
* @type {boolean}
* @readonly
*/
get selfDeaf() { return this.voiceState.self_deaf; }
/**
* The voice session ID of this member (if any)
* @type {?Snowflake}
* @readonly
*/
get voiceSessionID() { return this.voiceState.session_id; }
/**
* The voice channel ID of this member, (if any)
* @type {?Snowflake}
* @readonly
*/
get voiceChannelID() { return this.voiceState.channel_id; }
@@ -515,8 +521,7 @@ class GuildMember extends Base {
/**
* Bans this guild member.
* @param {Object} [options] Ban options. If a number, the number of days to delete messages for, if a
* string, the ban reason. Supplying an object allows you to do both.
* @param {Object} [options] Options for the ban
* @param {number} [options.days=0] Number of days of messages to delete
* @param {string} [options.reason] Reason for banning
* @returns {Promise<GuildMember>}

View File

@@ -8,9 +8,9 @@ const Collection = require('../util/Collection');
const ReactionStore = require('../stores/ReactionStore');
const { MessageTypes } = require('../util/Constants');
const Permissions = require('../util/Permissions');
const GuildMember = require('./GuildMember');
const Base = require('./Base');
const { Error, TypeError } = require('../errors');
const { createMessage } = require('./shared');
/**
* Represents a message on Discord.
@@ -368,41 +368,22 @@ class Message extends Base {
* .then(msg => console.log(`Updated the content of a message from ${msg.author}`))
* .catch(console.error);
*/
edit(content, options) {
async edit(content, options) {
if (!options && typeof content === 'object' && !(content instanceof Array)) {
options = content;
content = '';
content = null;
} else if (!options) {
options = {};
}
if (options instanceof Embed) options = { embed: options };
if (!options.content) options.content = content;
if (typeof options.content !== 'undefined') content = options.content;
if (typeof content !== 'undefined') content = Util.resolveString(content);
let { embed, code, reply } = options;
if (embed) embed = new Embed(embed)._apiTransform();
// Wrap everything in a code block
if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) {
content = Util.escapeMarkdown(Util.resolveString(content), true);
content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``;
}
// Add the reply prefix
if (reply && this.channel.type !== 'dm') {
const id = this.client.users.resolveID(reply);
const mention = `<@${reply instanceof GuildMember && reply.nickname ? '!' : ''}${id}>`;
content = `${mention}${content ? `, ${content}` : ''}`;
}
const { data, files } = await createMessage(this, options);
return this.client.api.channels[this.channel.id].messages[this.id]
.patch({ data: { content, embed } })
.then(data => {
.patch({ data, files })
.then(d => {
const clone = this._clone();
clone._patch(data);
clone._patch(d);
return clone;
});
}

View File

@@ -117,19 +117,27 @@ class MessageMentions {
}
/**
* Check if a user is mentioned.
* Checks if a user, guild member, role, or channel is mentioned.
* Takes into account user mentions, role mentions, and @everyone/@here mentions.
* @param {UserResolvable|GuildMember|Role|GuildChannel} data User/GuildMember/Role/Channel to check
* @param {boolean} [strict=true] If role mentions and everyone/here mentions should be included
* @param {Object} [options] Options
* @param {boolean} [options.ignoreDirect=false] - Whether to ignore direct mentions to the item
* @param {boolean} [options.ignoreRoles=false] - Whether to ignore role mentions to a guild member
* @param {boolean} [options.ignoreEveryone=false] - Whether to ignore everyone/here mentions
* @returns {boolean}
*/
has(data, strict = true) {
if (strict && this.everyone) return true;
if (strict && data instanceof GuildMember) {
has(data, { ignoreDirect = false, ignoreRoles = false, ignoreEveryone = false } = {}) {
if (!ignoreEveryone && this.everyone) return true;
if (!ignoreRoles && data instanceof GuildMember) {
for (const role of this.roles.values()) if (data.roles.has(role.id)) return true;
}
const id = data.id || data;
return this.users.has(id) || this.channels.has(id) || this.roles.has(id);
if (!ignoreDirect) {
const id = data.id || data;
return this.users.has(id) || this.channels.has(id) || this.roles.has(id);
}
return false;
}
}

View File

@@ -1,6 +1,6 @@
const Collection = require('../util/Collection');
const Emoji = require('./Emoji');
const ReactionEmoji = require('./ReactionEmoji');
const ReactionUserStore = require('../stores/ReactionUserStore');
const { Error } = require('../errors');
/**
@@ -28,9 +28,9 @@ class MessageReaction {
/**
* The users that have given this reaction, mapped by their ID
* @type {Collection<Snowflake, User>}
* @type {ReactionUserStore<Snowflake, User>}
*/
this.users = new Collection();
this.users = new ReactionUserStore(client, undefined, this);
this._emoji = new ReactionEmoji(this, data.emoji.name, data.emoji.id);
}
@@ -77,26 +77,6 @@ class MessageReaction {
);
}
/**
* Fetches all the users that gave this reaction. Resolves with a collection of users, mapped by their IDs.
* @param {Object} [options] Options for fetching the users
* @param {number} [options.limit=100] The maximum amount of users to fetch, defaults to 100
* @param {Snowflake} [options.after] Limit fetching users to those with an id greater than the supplied id
* @returns {Promise<Collection<Snowflake, User>>}
*/
async fetchUsers({ limit = 100, after } = {}) {
const message = this.message;
const users = await message.client.api.channels[message.channel.id].messages[message.id]
.reactions[this.emoji.identifier]
.get({ query: { limit, after } });
for (const rawUser of users) {
const user = message.client.users.create(rawUser);
this.users.set(user.id, user);
}
this.count = this.users.size;
return this.users;
}
_add(user) {
if (!this.users.has(user.id)) {
this.users.set(user.id, user);

View File

@@ -83,7 +83,17 @@ class ReactionCollector extends Collector {
* @returns {?Snowflake|string}
*/
dispose(reaction) {
return reaction.message.id === this.message.id && !reaction.count ? ReactionCollector.key(reaction) : null;
if (reaction.message.id !== this.message.id) return null;
/**
* Emitted whenever a reaction is removed from a message. Will emit on all reaction removals,
* as opposed to {@link Collector#dispose} which will only be emitted when the entire reaction
* is removed.
* @event ReactionCollector#remove
* @param {MessageReaction} reaction The reaction that was removed
*/
if (this.collected.has(reaction)) this.emit('remove', reaction);
return reaction.count ? null : ReactionCollector.key(reaction);
}
/**

View File

@@ -1,9 +1,5 @@
const Util = require('../util/Util');
const DataResolver = require('../util/DataResolver');
const Embed = require('./MessageEmbed');
const MessageAttachment = require('./MessageAttachment');
const MessageEmbed = require('./MessageEmbed');
const { browser } = require('../util/Constants');
const { createMessage } = require('./shared');
/**
* Represents a webhook.
@@ -98,115 +94,37 @@ class Webhook {
* .catch(console.error);
*/
/* eslint-enable max-len */
send(content, options) { // eslint-disable-line complexity
async send(content, options) { // eslint-disable-line complexity
if (!options && typeof content === 'object' && !(content instanceof Array)) {
options = content;
content = '';
content = null;
} else if (!options) {
options = {};
}
if (!options.content) options.content = content;
if (options instanceof MessageAttachment) options = { files: [options.file] };
if (options instanceof MessageEmbed) options = { embeds: [options] };
if (options.embed) options = { embeds: [options.embed] };
const { data, files } = await createMessage(this, options);
if (content instanceof Array || options instanceof Array) {
const which = content instanceof Array ? content : options;
const attachments = which.filter(item => item instanceof MessageAttachment);
const embeds = which.filter(item => item instanceof MessageEmbed);
if (attachments.length) options = { files: attachments };
if (embeds.length) options = { embeds };
if ((embeds.length || attachments.length) && content instanceof Array) content = '';
}
if (!options.username) options.username = this.name;
if (options.avatarURL) {
options.avatar_url = options.avatarURL;
options.avatarURL = null;
}
if (content) {
content = Util.resolveString(content);
let { split, code, disableEveryone } = options;
if (split && typeof split !== 'object') split = {};
if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) {
content = Util.escapeMarkdown(content, true);
content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``;
if (split) {
split.prepend = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n`;
split.append = '\n```';
}
if (data.content instanceof Array) {
const messages = [];
for (let i = 0; i < data.content.length; i++) {
const opt = i === data.content.length - 1 ? { embeds: data.embeds, files } : {};
Object.assign(opt, { avatarURL: data.avatar_url, content: data.content[i], username: data.username });
// eslint-disable-next-line no-await-in-loop
const message = await this.send(data.content[i], opt);
messages.push(message);
}
if (disableEveryone || (typeof disableEveryone === 'undefined' && this.client.options.disableEveryone)) {
content = content.replace(/@(everyone|here)/g, '@\u200b$1');
}
if (split) content = Util.splitMessage(content, split);
}
options.content = content;
if (options.embeds) options.embeds = options.embeds.map(embed => new Embed(embed)._apiTransform());
if (options.files) {
for (let i = 0; i < options.files.length; i++) {
let file = options.files[i];
if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file };
if (!file.name) {
if (typeof file.attachment === 'string') {
file.name = Util.basename(file.attachment);
} else if (file.attachment && file.attachment.path) {
file.name = Util.basename(file.attachment.path);
} else if (file instanceof MessageAttachment) {
file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' };
} else {
file.name = 'file.jpg';
}
} else if (file instanceof MessageAttachment) {
file = file.file;
}
options.files[i] = file;
}
return Promise.all(options.files.map(file =>
DataResolver.resolveFile(file.attachment).then(resource => {
file.file = resource;
return file;
})
)).then(files => this.client.api.webhooks(this.id, this.token).post({
data: options,
query: { wait: true },
files,
auth: false,
}));
return messages;
}
if (content instanceof Array) {
return new Promise((resolve, reject) => {
const messages = [];
(function sendChunk() {
const opt = content.length ? null : { embeds: options.embeds, files: options.files };
this.client.api.webhooks(this.id, this.token).post({
data: { content: content.shift(), opt },
query: { wait: true },
auth: false,
})
.then(message => {
messages.push(message);
if (content.length === 0) return resolve(messages);
return sendChunk.call(this);
})
.catch(reject);
}.call(this));
});
}
return this.client.api.webhooks(this.id, this.token).post({
data: options,
data, files,
query: { wait: true },
auth: false,
}).then(data => {
if (!this.client.channels) return data;
return this.client.channels.get(data.channel_id).messages.create(data, false);
}).then(d => {
if (!this.client.channels) return d;
return this.client.channels.get(d.channel_id).messages.create(d, false);
});
}

View File

@@ -1,12 +1,7 @@
const MessageCollector = require('../MessageCollector');
const Shared = require('../shared');
const Util = require('../../util/Util');
const { browser } = require('../../util/Constants');
const Snowflake = require('../../util/Snowflake');
const Collection = require('../../util/Collection');
const DataResolver = require('../../util/DataResolver');
const MessageAttachment = require('../../structures/MessageAttachment');
const MessageEmbed = require('../../structures/MessageEmbed');
const { RangeError, TypeError } = require('../../errors');
/**
@@ -80,61 +75,12 @@ class TextBasedChannel {
send(content, options) { // eslint-disable-line complexity
if (!options && typeof content === 'object' && !(content instanceof Array)) {
options = content;
content = '';
content = null;
} else if (!options) {
options = {};
}
if (options instanceof MessageEmbed) options = { embed: options };
if (options instanceof MessageAttachment) options = { files: [options.file] };
if (content instanceof Array || options instanceof Array) {
const which = content instanceof Array ? content : options;
const attachments = which.filter(item => item instanceof MessageAttachment);
if (attachments.length) {
options = { files: attachments };
if (content instanceof Array) content = '';
}
}
if (!options.content) options.content = content;
if (options.embed && options.embed.files) {
if (options.files) options.files = options.files.concat(options.embed.files);
else options.files = options.embed.files;
}
if (options.files) {
for (let i = 0; i < options.files.length; i++) {
let file = options.files[i];
if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file };
if (!file.name) {
if (typeof file.attachment === 'string') {
file.name = Util.basename(file.attachment);
} else if (file.attachment && file.attachment.path) {
file.name = Util.basename(file.attachment.path);
} else if (file instanceof MessageAttachment) {
file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' };
} else {
file.name = 'file.jpg';
}
} else if (file instanceof MessageAttachment) {
file = file.file;
}
options.files[i] = file;
}
return Promise.all(options.files.map(file =>
DataResolver.resolveFile(file.attachment).then(resource => {
file.file = resource;
return file;
})
)).then(files => {
options.files = files;
return Shared.sendMessage(this, options);
});
}
return Shared.sendMessage(this, options);
}
@@ -158,26 +104,45 @@ class TextBasedChannel {
/**
* Starts a typing indicator in the channel.
* @param {number} [count] The number of times startTyping should be considered to have been called
* @param {number} [count=1] The number of times startTyping should be considered to have been called
* @returns {Promise} Resolves once the bot stops typing gracefully, or rejects when an error occurs
* @example
* // Start typing in a channel
* // Start typing in a channel, or increase the typing count by one
* channel.startTyping();
* @example
* // Start typing in a channel with a typing count of five, or set it to five
* channel.startTyping(5);
*/
startTyping(count) {
if (typeof count !== 'undefined' && count < 1) throw new RangeError('TYPING_COUNT');
if (!this.client.user._typing.has(this.id)) {
const endpoint = this.client.api.channels[this.id].typing;
this.client.user._typing.set(this.id, {
count: count || 1,
interval: this.client.setInterval(() => {
endpoint.post();
}, 9000),
});
endpoint.post();
} else {
if (this.client.user._typing.has(this.id)) {
const entry = this.client.user._typing.get(this.id);
entry.count = count || entry.count + 1;
return entry.promise;
}
const entry = {};
entry.promise = new Promise((resolve, reject) => {
const endpoint = this.client.api.channels[this.id].typing;
Object.assign(entry, {
count: count || 1,
interval: this.client.setInterval(() => {
endpoint.post().catch(error => {
this.client.clearInterval(entry.interval);
this.client.user._typing.delete(this.id);
reject(error);
});
}, 9000),
resolve,
});
endpoint.post().catch(error => {
this.client.clearInterval(entry.interval);
this.client.user._typing.delete(this.id);
reject(error);
});
this.client.user._typing.set(this.id, entry);
});
return entry.promise;
}
/**
@@ -186,10 +151,10 @@ class TextBasedChannel {
* <info>It can take a few seconds for the client user to stop typing.</info>
* @param {boolean} [force=false] Whether or not to reset the call count and force the indicator to stop
* @example
* // Stop typing in a channel
* // Reduce the typing count by one and stop typing if it reached 0
* channel.stopTyping();
* @example
* // Force typing to fully stop in a channel
* // Force typing to fully stop regardless of typing count
* channel.stopTyping(true);
*/
stopTyping(force = false) {
@@ -199,6 +164,7 @@ class TextBasedChannel {
if (entry.count <= 0 || force) {
this.client.clearInterval(entry.interval);
this.client.user._typing.delete(this.id);
entry.resolve();
}
}
}

View File

@@ -0,0 +1,108 @@
const Embed = require('../MessageEmbed');
const DataResolver = require('../../util/DataResolver');
const MessageEmbed = require('../MessageEmbed');
const MessageAttachment = require('../MessageAttachment');
const { browser } = require('../../util/Constants');
const Util = require('../../util/Util');
// eslint-disable-next-line complexity
module.exports = async function createMessage(channel, options) {
const User = require('../User');
const GuildMember = require('../GuildMember');
const Webhook = require('../Webhook');
const WebhookClient = require('../../client/WebhookClient');
const webhook = channel instanceof Webhook || channel instanceof WebhookClient;
if (typeof options.nonce !== 'undefined') {
options.nonce = parseInt(options.nonce);
if (isNaN(options.nonce) || options.nonce < 0) throw new RangeError('MESSAGE_NONCE_TYPE');
}
if (options instanceof MessageEmbed) options = webhook ? { embeds: [options] } : { embed: options };
if (options instanceof MessageAttachment) options = { files: [options.file] };
if (options.reply && !(channel instanceof User || channel instanceof GuildMember) && channel.type !== 'dm') {
const id = channel.client.users.resolveID(options.reply);
const mention = `<@${options.reply instanceof GuildMember && options.reply.nickname ? '!' : ''}${id}>`;
if (options.split) options.split.prepend = `${mention}, ${options.split.prepend || ''}`;
options.content = `${mention}${typeof options.content !== 'undefined' ? `, ${options.content}` : ''}`;
}
if (options.content) {
options.content = Util.resolveString(options.content);
if (options.split && typeof options.split !== 'object') options.split = {};
// Wrap everything in a code block
if (typeof options.code !== 'undefined' && (typeof options.code !== 'boolean' || options.code === true)) {
options.content = Util.escapeMarkdown(options.content, true);
options.content =
`\`\`\`${typeof options.code !== 'boolean' ? options.code || '' : ''}\n${options.content}\n\`\`\``;
if (options.split) {
options.split.prepend = `\`\`\`${typeof options.code !== 'boolean' ? options.code || '' : ''}\n`;
options.split.append = '\n```';
}
}
// Add zero-width spaces to @everyone/@here
if (options.disableEveryone ||
(typeof options.disableEveryone === 'undefined' && channel.client.options.disableEveryone)) {
options.content = options.content.replace(/@(everyone|here)/g, '@\u200b$1');
}
if (options.split) options.content = Util.splitMessage(options.content, options.split);
}
if (options.embed && options.embed.files) {
if (options.files) options.files = options.files.concat(options.embed.files);
else options.files = options.embed.files;
}
if (options.embed && webhook) options.embeds = [new Embed(options.embed)._apiTransform()];
else if (options.embed) options.embed = new Embed(options.embed)._apiTransform();
else if (options.embeds) options.embeds = options.embeds.map(e => new Embed(e)._apiTransform());
let files;
if (options.files) {
for (let i = 0; i < options.files.length; i++) {
let file = options.files[i];
if (typeof file === 'string' || (!browser && Buffer.isBuffer(file))) file = { attachment: file };
if (!file.name) {
if (typeof file.attachment === 'string') {
file.name = Util.basename(file.attachment);
} else if (file.attachment && file.attachment.path) {
file.name = Util.basename(file.attachment.path);
} else if (file instanceof MessageAttachment) {
file = { attachment: file.file, name: Util.basename(file.file) || 'file.jpg' };
} else {
file.name = 'file.jpg';
}
} else if (file instanceof MessageAttachment) {
file = file.file;
}
options.files[i] = file;
}
files = await Promise.all(options.files.map(file =>
DataResolver.resolveFile(file.attachment).then(resource => {
file.file = resource;
return file;
})
));
}
if (webhook) {
if (!options.username) options.username = this.name;
if (options.avatarURL) options.avatar_url = options.avatarURL;
}
return { data: {
content: options.content,
tts: options.tts,
nonce: options.nonce,
embed: options.embed,
embeds: options.embeds,
username: options.username,
avatar_url: options.avatar_url,
}, files };
};

View File

@@ -1,4 +1,4 @@
const long = require('long');
const Util = require('../../util/Util');
const { TypeError } = require('../../errors');
/**
@@ -40,17 +40,17 @@ module.exports = function search(target, options) {
if (typeof options === 'string') options = { content: options };
if (options.before) {
if (!(options.before instanceof Date)) options.before = new Date(options.before);
options.maxID = long.fromNumber(options.before.getTime() - 14200704e5).shiftLeft(22).toString();
options.maxID = Util.binaryToID((options.before.getTime() - 14200704e5).toString(2) + '0'.repeat(22));
}
if (options.after) {
if (!(options.after instanceof Date)) options.after = new Date(options.after);
options.minID = long.fromNumber(options.after.getTime() - 14200704e5).shiftLeft(22).toString();
options.minID = Util.binaryToID((options.after.getTime() - 14200704e5).toString(2) + '0'.repeat(22));
}
if (options.during) {
if (!(options.during instanceof Date)) options.during = new Date(options.during);
const t = options.during.getTime() - 14200704e5;
options.minID = long.fromNumber(t).shiftLeft(22).toString();
options.maxID = long.fromNumber(t + 864e5).shiftLeft(22).toString();
options.minID = Util.binaryToID(t.toString(2) + '0'.repeat(22));
options.maxID = Util.binaryToID((t + 864e5).toString(2) + '0'.repeat(22));
}
if (options.channel) options.channel = target.client.channels.resolveID(options.channel);
if (options.author) options.author = target.client.users.resolveID(options.author);

View File

@@ -1,65 +1,23 @@
const Util = require('../../util/Util');
const Embed = require('../MessageEmbed');
const { RangeError } = require('../../errors');
const createMessage = require('./CreateMessage');
module.exports = function sendMessage(channel, options) { // eslint-disable-line complexity
module.exports = async function sendMessage(channel, options) { // eslint-disable-line complexity
const User = require('../User');
const GuildMember = require('../GuildMember');
if (channel instanceof User || channel instanceof GuildMember) return channel.createDM().then(dm => dm.send(options));
let { content, nonce, reply, code, disableEveryone, tts, embed, files, split } = options;
if (embed) embed = new Embed(embed)._apiTransform();
const { data, files } = await createMessage(channel, options);
if (typeof nonce !== 'undefined') {
nonce = parseInt(nonce);
if (isNaN(nonce) || nonce < 0) throw new RangeError('MESSAGE_NONCE_TYPE');
}
// Add the reply prefix
if (reply && !(channel instanceof User || channel instanceof GuildMember) && channel.type !== 'dm') {
const id = channel.client.users.resolveID(reply);
const mention = `<@${reply instanceof GuildMember && reply.nickname ? '!' : ''}${id}>`;
if (split) split.prepend = `${mention}, ${split.prepend || ''}`;
content = `${mention}${typeof content !== 'undefined' ? `, ${content}` : ''}`;
}
if (content) {
content = Util.resolveString(content);
if (split && typeof split !== 'object') split = {};
// Wrap everything in a code block
if (typeof code !== 'undefined' && (typeof code !== 'boolean' || code === true)) {
content = Util.escapeMarkdown(content, true);
content = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n${content}\n\`\`\``;
if (split) {
split.prepend = `\`\`\`${typeof code !== 'boolean' ? code || '' : ''}\n`;
split.append = '\n```';
}
if (data.content instanceof Array) {
const messages = [];
for (let i = 0; i < data.content.length; i++) {
const opt = i === data.content.length - 1 ? { tts: data.tts, embed: data.embed, files } : { tts: data.tts };
// eslint-disable-next-line no-await-in-loop
const message = await channel.send(data.content[i], opt);
messages.push(message);
}
// Add zero-width spaces to @everyone/@here
if (disableEveryone || (typeof disableEveryone === 'undefined' && channel.client.options.disableEveryone)) {
content = content.replace(/@(everyone|here)/g, '@\u200b$1');
}
if (split) content = Util.splitMessage(content, split);
return messages;
}
if (content instanceof Array) {
return new Promise((resolve, reject) => {
const messages = [];
(function sendChunk() {
const opt = content.length ? { tts } : { tts, embed, files };
channel.send(content.shift(), opt).then(message => {
messages.push(message);
if (content.length === 0) return resolve(messages);
return sendChunk();
}).catch(reject);
}());
});
}
return channel.client.api.channels[channel.id].messages.post({
data: { content, tts, nonce, embed },
files,
}).then(data => channel.client.actions.MessageCreate.handle(data).message);
return channel.client.api.channels[channel.id].messages.post({ data, files })
.then(d => channel.client.actions.MessageCreate.handle(d).message);
};

View File

@@ -1,4 +1,5 @@
module.exports = {
search: require('./Search'),
sendMessage: require('./SendMessage'),
createMessage: require('./CreateMessage'),
};

View File

@@ -52,7 +52,7 @@ exports.DefaultOptions = {
* 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
* @property {boolean} [compress=false] Whether to compress data sent on the connection
* (defaults to `false` for browsers)
*/
ws: {

View File

@@ -1,4 +1,4 @@
const Long = require('long');
const Util = require('../util/Util');
// Discord epoch (2015-01-01T00:00:00.000Z)
const EPOCH = 1420070400000;
@@ -31,8 +31,9 @@ class SnowflakeUtil {
*/
static generate() {
if (INCREMENT >= 4095) INCREMENT = 0;
const BINARY = `${pad((Date.now() - EPOCH).toString(2), 42)}0000100000${pad((INCREMENT++).toString(2), 12)}`;
return Long.fromString(BINARY, 2).toString();
// eslint-disable-next-line max-len
const BINARY = `${(Date.now() - EPOCH).toString(2).padStart(42, '0')}0000100000${(INCREMENT++).toString(2).padStart(12, '0')}`;
return Util.binaryToID(BINARY);
}
/**
@@ -52,7 +53,7 @@ class SnowflakeUtil {
* @returns {DeconstructedSnowflake} Deconstructed snowflake
*/
static deconstruct(snowflake) {
const BINARY = pad(Long.fromString(snowflake).toString(2), 64);
const BINARY = Util.idToBinary(snowflake).toString(2).padStart(64, '0');
const res = {
timestamp: parseInt(BINARY.substring(0, 42), 2) + EPOCH,
workerID: parseInt(BINARY.substring(42, 47), 2),
@@ -68,8 +69,4 @@ class SnowflakeUtil {
}
}
function pad(v, n, c = '0') {
return String(v).length >= n ? String(v) : (String(c).repeat(n) + v).slice(-n);
}
module.exports = SnowflakeUtil;

80
src/util/Structures.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Allows for the extension of built-in Discord.js structures that are instantiated by {@link DataStore DataStores}.
*/
class Structures {
constructor() {
throw new Error(`The ${this.constructor.name} class may not be instantiated.`);
}
/**
* Retrieves a structure class.
* @param {string} structure Name of the structure to retrieve
* @returns {Function}
*/
static get(structure) {
if (typeof structure === 'string') return structures[structure];
throw new TypeError(`"structure" argument must be a string (received ${typeof structure})`);
}
/**
* Extends a structure.
* @param {string} structure Name of the structure class to extend
* @param {Function} extender Function that takes the base class to extend as its only parameter and returns the
* extended class/prototype
* @returns {Function} Extended class/prototype returned from the extender
* @example
* const { Structures } = require('discord.js');
*
* Structures.extend('Guild', Guild => {
* class CoolGuild extends Guild {
* constructor(client, data) {
* super(client, data);
* this.cool = true;
* }
* }
*
* return CoolGuild;
* });
*/
static extend(structure, extender) {
if (!structures[structure]) throw new RangeError(`"${structure}" is not a valid extensible structure.`);
if (typeof extender !== 'function') {
const received = `(received ${typeof extender})`;
throw new TypeError(
`"extender" argument must be a function that returns the extended structure class/prototype ${received}`
);
}
const extended = extender(structures[structure]);
if (typeof extended !== 'function') {
throw new TypeError('The extender function must return the extended structure class/prototype.');
}
if (Object.getPrototypeOf(extended) !== structures[structure]) {
throw new Error(
'The class/prototype returned from the extender function must extend the existing structure class/prototype.'
);
}
structures[structure] = extended;
return extended;
}
}
const structures = {
Emoji: require('../structures/Emoji'),
DMChannel: require('../structures/DMChannel'),
GroupDMChannel: require('../structures/GroupDMChannel'),
TextChannel: require('../structures/TextChannel'),
VoiceChannel: require('../structures/VoiceChannel'),
CategoryChannel: require('../structures/CategoryChannel'),
GuildChannel: require('../structures/GuildChannel'),
GuildMember: require('../structures/GuildMember'),
Guild: require('../structures/Guild'),
Message: require('../structures/Message'),
MessageReaction: require('../structures/MessageReaction'),
Presence: require('../structures/Presence').Presence,
Role: require('../structures/Role'),
User: require('../structures/User'),
};
module.exports = Structures;

View File

@@ -1,4 +1,3 @@
const Long = require('long');
const snekfetch = require('snekfetch');
const { Colors, DefaultOptions, Endpoints } = require('./Constants');
const { Error: DiscordError, RangeError, TypeError } = require('../errors');
@@ -22,9 +21,7 @@ class Util {
static splitMessage(text, { maxLength = 1950, char = '\n', prepend = '', append = '' } = {}) {
if (text.length <= maxLength) return text;
const splitText = text.split(char);
if (splitText.length === 1) {
throw new RangeError('SPLIT_MAX_LEN');
}
if (splitText.length === 1) throw new RangeError('SPLIT_MAX_LEN');
const messages = [''];
let msg = 0;
for (let i = 0; i < splitText.length; i++) {
@@ -84,10 +81,7 @@ class Util {
const [name, id] = text.split(':');
return { name, id };
} else {
return {
name: text,
id: null,
};
return { name: text, id: null };
}
}
@@ -226,7 +220,6 @@ class Util {
* @param {StringResolvable} data The string resolvable to resolve
* @returns {string}
*/
static resolveString(data) {
if (typeof data === 'string') return data;
if (data instanceof Array) return data.join('\n');
@@ -234,39 +227,34 @@ class Util {
}
/**
* Can be a Hex Literal, Hex String, Number, RGB Array, or one of the following
* Can be a number, hex string, an RGB array like:
* ```js
* [255, 0, 255] // purple
* ```
* [
* 'DEFAULT',
* 'AQUA',
* 'GREEN',
* 'BLUE',
* 'PURPLE',
* 'GOLD',
* 'ORANGE',
* 'RED',
* 'GREY',
* 'DARKER_GREY',
* 'NAVY',
* 'DARK_AQUA',
* 'DARK_GREEN',
* 'DARK_BLUE',
* 'DARK_PURPLE',
* 'DARK_GOLD',
* 'DARK_ORANGE',
* 'DARK_RED',
* 'DARK_GREY',
* 'LIGHT_GREY',
* 'DARK_NAVY',
* 'RANDOM',
* ]
* ```
* or something like
* ```
* [255, 0, 255]
* ```
* for purple
* @typedef {string|number|Array} ColorResolvable
* or one of the following strings:
* - `DEFAULT`
* - `AQUA`
* - `GREEN`
* - `BLUE`
* - `PURPLE`
* - `GOLD`
* - `ORANGE`
* - `RED`
* - `GREY`
* - `DARKER_GREY`
* - `NAVY`
* - `DARK_AQUA`
* - `DARK_GREEN`
* - `DARK_BLUE`
* - `DARK_PURPLE`
* - `DARK_GOLD`
* - `DARK_ORANGE`
* - `DARK_RED`
* - `DARK_GREY`
* - `LIGHT_GREY`
* - `DARK_NAVY`
* - `RANDOM`
* @typedef {string|number|number[]} ColorResolvable
*/
/**
@@ -274,7 +262,6 @@ class Util {
* @param {ColorResolvable} color Color to resolve
* @returns {number} A color
*/
static resolveColor(color) {
if (typeof color === 'string') {
if (color === 'RANDOM') return Math.floor(Math.random() * (0xFFFFFF + 1));
@@ -283,25 +270,36 @@ class Util {
color = (color[0] << 16) + (color[1] << 8) + color[2];
}
if (color < 0 || color > 0xFFFFFF) {
throw new RangeError('COLOR_RANGE');
} else if (color && isNaN(color)) {
throw new TypeError('COLOR_CONVERT');
}
if (color < 0 || color > 0xFFFFFF) throw new RangeError('COLOR_RANGE');
else if (color && isNaN(color)) throw new TypeError('COLOR_CONVERT');
return color;
}
/**
* Sorts by Discord's position and then by ID.
* Sorts by Discord's position and ID.
* @param {Collection} collection Collection of objects to sort
* @returns {Collection}
*/
static discordSort(collection) {
return collection
.sort((a, b) => a.rawPosition - b.rawPosition || Long.fromString(a.id).sub(Long.fromString(b.id)).toNumber());
return collection.sort((a, b) =>
a.rawPosition - b.rawPosition ||
parseInt(a.id.slice(0, -10)) - parseInt(b.id.slice(0, -10)) ||
parseInt(a.id.slice(10)) - parseInt(b.id.slice(10))
);
}
/**
* Sets the position of a Channel or Role.
* @param {Channel|Role} item Object to set the position of
* @param {number} position New position for the object
* @param {boolean} relative Whether `position` is relative to its current position
* @param {Collection<string, Channel|Role>} sorted A collection of the objects sorted properly
* @param {APIRouter} route Route to call PATCH on
* @param {string} [reason] Reason for the change
* @returns {Promise<Object[]>} Updated item list, with `id` and `position` properties
* @private
*/
static setPosition(item, position, relative, sorted, route, reason) {
let updatedItems = sorted.array();
Util.moveElementInArray(updatedItems, item, position, relative);
@@ -309,13 +307,77 @@ class Util {
return route.patch({ data: updatedItems, reason }).then(() => updatedItems);
}
/**
* Alternative to Node's `path.basename` that we have for some (probably stupid) reason.
* @param {string} path Path to get the basename of
* @param {string} [ext] File extension to remove
* @returns {string} Basename of the path
* @private
*/
static basename(path, ext) {
let f = splitPathRe.exec(path).slice(1)[2];
if (ext && f.substr(-1 * ext.length) === ext) {
f = f.substr(0, f.length - ext.length);
}
if (ext && f.substr(-1 * ext.length) === ext) f = f.substr(0, f.length - ext.length);
return f;
}
/**
* Transforms a snowflake from a decimal string to a bit string.
* @param {Snowflake} num Snowflake to be transformed
* @returns {string}
* @private
*/
static idToBinary(num) {
let bin = '';
let high = parseInt(num.slice(0, -10)) || 0;
let low = parseInt(num.slice(-10));
while (low > 0 || high > 0) {
bin = String(low & 1) + bin;
low = Math.floor(low / 2);
if (high > 0) {
low += 5000000000 * (high % 2);
high = Math.floor(high / 2);
}
}
return bin;
}
/**
* Transforms a snowflake from a bit string to a decimal string.
* @param {string} num Bit string to be transformed
* @returns {Snowflake}
* @private
*/
static binaryToID(num) {
let dec = '';
while (num.length > 50) {
const high = parseInt(num.slice(0, -32), 2);
const low = parseInt((high % 10).toString(2) + num.slice(-32), 2);
dec = (low % 10).toString() + dec;
num = Math.floor(high / 10).toString(2) + Math.floor(low / 10).toString(2).padStart(32, '0');
}
num = parseInt(num, 2);
while (num > 0) {
dec = (num % 10).toString() + dec;
num = Math.floor(num / 10);
}
return dec;
}
/**
* Creates a Promise that resolves after a specified duration.
* @param {number} ms How long to wait before resolving (in milliseconds)
* @returns {Promise<void>}
* @private
*/
static delayFor(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
}
module.exports = Util;

View File

@@ -24,12 +24,6 @@ else
SOURCE_TYPE="branch"
fi
# For Node != 8, do nothing
if [ "$TRAVIS_NODE_VERSION" != "8" ]; then
echo -e "\e[36m\e[1mBuild triggered with Node v${TRAVIS_NODE_VERSION} - doing nothing."
exit 0
fi
# Run the build
npm run docs
VERSIONED=false npm run webpack
@@ -88,4 +82,3 @@ git config user.name "Travis CI"
git config user.email "$COMMIT_AUTHOR_EMAIL"
git commit -m "Webpack build for ${SOURCE_TYPE} ${SOURCE}: ${SHA}" || true
git push $SSH_REPO $TARGET_BRANCH

Submodule typings updated: 5131e88ffe...0b5b13f4a5