diff --git a/src/structures/DMChannel.js b/src/structures/DMChannel.js index 2916d2469..9c74aad4e 100644 --- a/src/structures/DMChannel.js +++ b/src/structures/DMChannel.js @@ -1,5 +1,5 @@ const Channel = require('./Channel'); -const TextBasedChannel = require('./interface/TextBasedChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Collection = require('../util/Collection'); /** diff --git a/src/structures/GroupDMChannel.js b/src/structures/GroupDMChannel.js index 7b1a7987d..d080a2b06 100644 --- a/src/structures/GroupDMChannel.js +++ b/src/structures/GroupDMChannel.js @@ -1,5 +1,5 @@ const Channel = require('./Channel'); -const TextBasedChannel = require('./interface/TextBasedChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Collection = require('../util/Collection'); /* diff --git a/src/structures/GuildMember.js b/src/structures/GuildMember.js index 6fc8e9074..09dae24b3 100644 --- a/src/structures/GuildMember.js +++ b/src/structures/GuildMember.js @@ -1,4 +1,4 @@ -const TextBasedChannel = require('./interface/TextBasedChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Role = require('./Role'); const Permissions = require('../util/Permissions'); const Collection = require('../util/Collection'); diff --git a/src/structures/Message.js b/src/structures/Message.js index bff7961fe..b91343109 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -2,6 +2,7 @@ const Mentions = require('./MessageMentions'); const Attachment = require('./MessageAttachment'); const Embed = require('./MessageEmbed'); const MessageReaction = require('./MessageReaction'); +const ReactionCollector = require('./ReactionCollector'); const Util = require('../util/Util'); const Collection = require('../util/Collection'); const Constants = require('../util/Constants'); @@ -245,6 +246,47 @@ class Message { }); } + /** + * Creates a reaction collector. + * @param {CollectorFilter} filter The filter to apply. + * @param {ReactionCollectorOptions} [options={}] Options to send to the collector. + * @returns {ReactionCollector} + * @example + * // create a reaction collector + * const collector = message.createReactionCollector( + * (reaction, user) => reaction.emoji.id === '👌' && user.id === 'someID', + * { time: 15000 } + * ); + * collector.on('collect', r => console.log(`Collected ${r.emoji.name}`)); + * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); + */ + createReactionCollector(filter, options = {}) { + return new ReactionCollector(this, filter, options); + } + + /** + * An object containing the same properties as CollectorOptions, but a few more: + * @typedef {ReactionCollectorOptions} AwaitReactionsOptions + * @property {string[]} [errors] Stop/end reasons that cause the promise to reject + */ + + /** + * Similar to createCollector but in promise form. Resolves with a collection of reactions that pass the specified + * filter. + * @param {CollectorFilter} filter The filter function to use + * @param {AwaitReactionsOptions} [options={}] Optional options to pass to the internal collector + * @returns {Promise>} + */ + awaitReactions(filter, options = {}) { + return new Promise((resolve, reject) => { + const collector = this.createReactionCollector(filter, options); + collector.once('end', (reactions, reason) => { + if (options.errors && options.errors.includes(reason)) reject(reactions); + else resolve(reactions); + }); + }); + } + /** * An array of cached versions of the message, including the current version. * Sorted from latest (first) to oldest (last). diff --git a/src/structures/MessageCollector.js b/src/structures/MessageCollector.js index f35cb8c81..e932c128a 100644 --- a/src/structures/MessageCollector.js +++ b/src/structures/MessageCollector.js @@ -1,150 +1,73 @@ -const EventEmitter = require('events').EventEmitter; -const Collection = require('../util/Collection'); +const Collector = require('./interfaces/Collector'); /** - * Collects messages based on a specified filter, then emits them. - * @extends {EventEmitter} + * @typedef {CollectorOptions} MessageCollectorOptions + * @property {number} max The maximum amount of messages to process. + * @property {number} maxMatches The maximum amount of messages to collect. */ -class MessageCollector extends EventEmitter { - /** - * A function that takes a Message object and a MessageCollector and returns a boolean. - * ```js - * function(message, collector) { - * if (message.content.includes('discord')) { - * return true; // passed the filter test - * } - * return false; // failed the filter test - * } - * ``` - * @typedef {Function} CollectorFilterFunction - */ + +/** + * Collects messages on a channel. + * @implements {Collector} + */ +class MessageCollector extends Collector { /** - * An object containing options used to configure a MessageCollector. All properties are optional. - * @typedef {Object} CollectorOptions - * @property {number} [time] Duration for the collector in milliseconds - * @property {number} [max] Maximum number of messages to handle - * @property {number} [maxMatches] Maximum number of successfully filtered messages to obtain - */ - - /** - * @param {Channel} channel The channel to collect messages in - * @param {CollectorFilterFunction} filter The filter function - * @param {CollectorOptions} [options] Options for the collector + * @param {TextBasedChannel} channel The channel. + * @param {CollectorFilter} filter The filter to be applied to this collector. + * @param {MessageCollectorOptions} options The options to be applied to this collector. + * @emits MessageCollector#message */ constructor(channel, filter, options = {}) { - super(); + super(channel.client, filter, options); /** - * The channel this collector is operating on - * @type {Channel} + * @type {TextBasedChannel} channel The channel. */ this.channel = channel; /** - * A function used to filter messages that the collector collects. - * @type {CollectorFilterFunction} + * @type {number} received Total number of messages that were received in the + * channel during message collection. */ - this.filter = filter; + this.received = 0; - /** - * Options for the collecor. - * @type {CollectorOptions} - */ - this.options = options; + this.client.on('message', this.listener); - /** - * Whether this collector has stopped collecting messages. - * @type {boolean} - */ - this.ended = false; - - /** - * A collection of collected messages, mapped by message ID. - * @type {Collection} - */ - this.collected = new Collection(); - - this.listener = message => this.verify(message); - this.channel.client.on('message', this.listener); - if (options.time) this.channel.client.setTimeout(() => this.stop('time'), options.time); - } - - /** - * Verifies a message against the filter and options - * @private - * @param {Message} message The message - * @returns {boolean} - */ - verify(message) { - if (this.channel ? this.channel.id !== message.channel.id : false) return false; - if (this.filter(message, this)) { - this.collected.set(message.id, message); + // For backwards compatibility (remove in v12). + if (this.options.max) this.options.maxProcessed = this.options.max; + if (this.options.maxMatches) this.options.max = this.options.maxMatches; + this._reEmitter = message => { /** - * Emitted whenever the collector receives a message that passes the filter test. - * @param {Message} message The received message - * @param {MessageCollector} collector The collector the message passed through + * Emitted when the collector receives a message. * @event MessageCollector#message + * @param {Message} message The message. + * @deprecated */ - this.emit('message', message, this); - if (this.collected.size >= this.options.maxMatches) this.stop('matchesLimit'); - else if (this.options.max && this.collected.size === this.options.max) this.stop('limit'); - return true; - } - return false; + this.emit('message', message); + }; + this.on('collect', this._reEmitter); } - /** - * Returns a promise that resolves when a valid message is sent. Rejects - * with collected messages if the Collector ends before receiving a message. - * @type {Promise} - * @readonly - */ - get next() { - return new Promise((resolve, reject) => { - if (this.ended) { - reject(this.collected); - return; - } - - const cleanup = () => { - this.removeListener('message', onMessage); - this.removeListener('end', onEnd); - }; - - const onMessage = (...args) => { - cleanup(); - resolve(...args); - }; - - const onEnd = (...args) => { - cleanup(); - reject(...args); // eslint-disable-line prefer-promise-reject-errors - }; - - this.once('message', onMessage); - this.once('end', onEnd); - }); + handle(message) { + if (message.channel.id !== this.channel.id) return null; + this.received++; + return { + key: message.id, + value: message, + }; } - /** - * Stops the collector and emits `end`. - * @param {string} [reason='user'] An optional reason for stopping the collector - */ - stop(reason = 'user') { - if (this.ended) return; - this.ended = true; - this.channel.client.removeListener('message', this.listener); - /** - * Emitted when the Collector stops collecting. - * @param {Collection} collection A collection of messages collected - * during the lifetime of the collector, mapped by the ID of the messages. - * @param {string} reason The reason for the end of the collector. If it ended because it reached the specified time - * limit, this would be `time`. If you invoke `.stop()` without specifying a reason, this would be `user`. If it - * ended because it reached its message limit, it will be `limit`. - * @event MessageCollector#end - */ - this.emit('end', this.collected, reason); + postCheck() { + // Consider changing the end reasons for v12 + if (this.options.maxMatches && this.collected.size >= this.options.max) return 'matchesLimit'; + if (this.options.max && this.received >= this.options.maxProcessed) return 'limit'; + return null; + } + + cleanup() { + this.removeListener('collect', this._reEmitter); + this.client.removeListener('message', this.listener); } } diff --git a/src/structures/ReactionCollector.js b/src/structures/ReactionCollector.js new file mode 100644 index 000000000..ae03f3b19 --- /dev/null +++ b/src/structures/ReactionCollector.js @@ -0,0 +1,64 @@ +const Collector = require('./interfaces/Collector'); +const Collection = require('../util/Collection'); + +/** + * @typedef {CollectorOptions} ReactionCollectorOptions + * @property {number} max The maximum total amount of reactions to collect. + * @property {number} maxEmojis The maximum number of emojis to collect. + * @property {number} maxUsers The maximum number of users to react. + */ + +/** + * Collects reactions on messages. + * @implements {Collector} + */ +class ReactionCollector extends Collector { + + /** + * @param {Message} message The message upon which to collect reactions. + * @param {CollectorFilter} filter The filter to apply to this collector. + * @param {ReactionCollectorOptions} [options={}] The options to apply to this collector. + */ + constructor(message, filter, options = {}) { + super(message.client, filter, options); + + /** + * @type {Message} message The message. + */ + this.message = message; + + /** + * @type {Collection} users Users which have reacted. + */ + this.users = new Collection(); + + /** + * @type {number} total Total number of reactions collected. + */ + this.total = 0; + + this.client.on('messageReactionAdd', this.listener); + } + + handle(reaction) { + if (reaction.message.id !== this.message.id) return null; + return { + key: reaction.emoji.id || reaction.emoji.name, + value: reaction, + }; + } + + postCheck(reaction, user) { + this.users.set(user.id, user); + if (this.options.max && ++this.total >= this.options.max) return 'limit'; + if (this.options.maxEmojis && this.collected.size >= this.options.maxEmojis) return 'emojiLimit'; + if (this.options.maxUsers && this.users.size >= this.options.maxUsers) return 'userLimit'; + return null; + } + + cleanup() { + this.client.removeListener('messageReactionAdd', this.listener); + } +} + +module.exports = ReactionCollector; diff --git a/src/structures/TextChannel.js b/src/structures/TextChannel.js index 274eb8eb1..617c17a78 100644 --- a/src/structures/TextChannel.js +++ b/src/structures/TextChannel.js @@ -1,5 +1,5 @@ const GuildChannel = require('./GuildChannel'); -const TextBasedChannel = require('./interface/TextBasedChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Collection = require('../util/Collection'); /** @@ -89,6 +89,7 @@ class TextChannel extends GuildChannel { get typing() {} get typingCount() {} createCollector() {} + createMessageCollector() {} awaitMessages() {} bulkDelete() {} acknowledge() {} diff --git a/src/structures/User.js b/src/structures/User.js index 2dd64202f..e07ef393c 100644 --- a/src/structures/User.js +++ b/src/structures/User.js @@ -1,4 +1,4 @@ -const TextBasedChannel = require('./interface/TextBasedChannel'); +const TextBasedChannel = require('./interfaces/TextBasedChannel'); const Constants = require('../util/Constants'); const Presence = require('./Presence').Presence; const Snowflake = require('../util/Snowflake'); diff --git a/src/structures/interfaces/Collector.js b/src/structures/interfaces/Collector.js new file mode 100644 index 000000000..d9d394ba1 --- /dev/null +++ b/src/structures/interfaces/Collector.js @@ -0,0 +1,168 @@ +const Collection = require('../../util/Collection'); +const EventEmitter = require('events').EventEmitter; + +/** + * Filter to be applied to the collector. + * @typedef {Function} CollectorFilter + * @param {...*} args Any arguments received by the listener. + * @returns {boolean} To collect or not collect. + */ + +/** + * Options to be applied to the collector. + * @typedef {Object} CollectorOptions + * @property {number} [time] How long to run the collector for. + */ + +/** + * Interface for defining a new Collector. + * @interface + */ +class Collector extends EventEmitter { + constructor(client, filter, options = {}) { + super(); + + /** + * @type {Client} client The client. + */ + this.client = client; + + /** + * @type {CollectorFilter} filter The filter applied to this collector. + */ + this.filter = filter; + + /** + * @type {CollectorOptions} options The options of this collector. + */ + this.options = options; + + /** + * @type {Collection} collected The items collected by this collector. + */ + this.collected = new Collection(); + + /** + * @type {boolean} ended Whether this collector has finished collecting. + */ + this.ended = false; + + /** + * @type {?number} _timeout Timeout ID for cleanup. + * @private + */ + this._timeout = null; + + /** + * @type {Function} listener Call this to handle an event as a collectable element. + * Accepts any event data as parameters. + * @private + */ + this.listener = this._handle.bind(this); + if (options.time) this._timeout = this.client.setTimeout(() => this.stop('time'), options.time); + } + + /** + * @param {...*} args The arguments emitted by the listener. + * @emits Collector#collect + * @private + */ + _handle(...args) { + const collect = this.handle(...args); + if (!collect || !this.filter(...args)) return; + + this.collected.set(collect.key, collect.value); + + /** + * Emitted whenever an element is collected. + * @event Collector#collect + * @param {*} element The element that got collected. + * @param {Collector} collector The message collector. + */ + this.emit('collect', collect.value, this); + + const post = this.postCheck(...args); + if (post) this.stop(post); + } + + /** + * Return a promise that resolves with the next collected element; + * rejects with collected elements if the collector finishes without receving a next element. + * @type {Promise} + * @readonly + */ + get next() { + return new Promise((resolve, reject) => { + if (this.ended) { + reject(this.collected); + return; + } + + const cleanup = () => { + this.removeListener('collect', onCollect); + this.removeListener('end', onEnd); + }; + + const onCollect = item => { + cleanup(); + resolve(item); + }; + + const onEnd = () => { + cleanup(); + reject(this.collected); // eslint-disable-line prefer-promise-reject-errors + }; + + this.on('collect', onCollect); + this.on('end', onEnd); + }); + } + + /** + * Stop this collector and emit the `end` event. + * @param {string} [reason='user'] The reason this collector is ending. + * @emits Collector#end + */ + stop(reason = 'user') { + if (this.ended) return; + + if (this._timeout) this.client.clearTimeout(this._timeout); + this.ended = true; + this.cleanup(); + + /** + * Emitted when the collector is finished collecting. + * @event Collector#end + * @param {Collection} collected The elements collected by the collector. + * @param {string} reason The reason the collector ended. + */ + this.emit('end', this.collected, reason); + } + + /* eslint-disable no-empty-function, valid-jsdoc */ + /** + * @param {...*} args Any args the event listener emits. + * @returns {?{key: string, value}} Data to insert into collection, if any. + * @abstract + * @private + */ + handle() {} + + /** + * @param {...*} args Any args the event listener emits. + * @returns {?string} Reason to end the collector, if any. + * @abstract + * @private + */ + postCheck() {} + + /** + * Called when the collector is ending. + * @abstract + * @private + */ + cleanup() {} + /* eslint-enable no-empty-function, valid-jsdoc */ +} + +module.exports = Collector; diff --git a/src/structures/interface/TextBasedChannel.js b/src/structures/interfaces/TextBasedChannel.js similarity index 95% rename from src/structures/interface/TextBasedChannel.js rename to src/structures/interfaces/TextBasedChannel.js index f4fcc6a1f..3e33bf9a5 100644 --- a/src/structures/interface/TextBasedChannel.js +++ b/src/structures/interfaces/TextBasedChannel.js @@ -367,8 +367,19 @@ class TextBasedChannel { /** * Creates a Message Collector - * @param {CollectorFilterFunction} filter The filter to create the collector with - * @param {CollectorOptions} [options={}] The options to pass to the collector + * @param {CollectorFilter} filter The filter to create the collector with + * @param {MessageCollectorOptions} [options={}] The options to pass to the collector + * @returns {MessageCollector} + * @deprecated + */ + createCollector(filter, options) { + return this.createMessageCollector(filter, options); + } + + /** + * Creates a Message Collector + * @param {CollectorFilter} filter The filter to create the collector with + * @param {MessageCollectorOptions} [options={}] The options to pass to the collector * @returns {MessageCollector} * @example * // create a message collector @@ -379,20 +390,20 @@ class TextBasedChannel { * collector.on('message', m => console.log(`Collected ${m.content}`)); * collector.on('end', collected => console.log(`Collected ${collected.size} items`)); */ - createCollector(filter, options = {}) { + createMessageCollector(filter, options = {}) { return new MessageCollector(this, filter, options); } /** * An object containing the same properties as CollectorOptions, but a few more: - * @typedef {CollectorOptions} AwaitMessagesOptions + * @typedef {MessageCollectorOptions} AwaitMessagesOptions * @property {string[]} [errors] Stop/end reasons that cause the promise to reject */ /** * Similar to createCollector but in promise form. Resolves with a collection of messages that pass the specified * filter. - * @param {CollectorFilterFunction} filter The filter function to use + * @param {CollectorFilter} filter The filter function to use * @param {AwaitMessagesOptions} [options={}] Optional options to pass to the internal collector * @returns {Promise>} * @example @@ -406,7 +417,7 @@ class TextBasedChannel { awaitMessages(filter, options = {}) { return new Promise((resolve, reject) => { const collector = this.createCollector(filter, options); - collector.on('end', (collection, reason) => { + collector.once('end', (collection, reason) => { if (options.errors && options.errors.includes(reason)) { reject(collection); } else { @@ -465,6 +476,7 @@ exports.applyToClass = (structure, full = false, ignore = []) => { 'typingCount', 'fetchPinnedMessages', 'createCollector', + 'createMessageCollector', 'awaitMessages' ); }