const {
Message,
MessageEmbed,
EmojiResolvable,
UserResolvable,
} = require('discord.js');
const Discord = require('discord.js');
const { validateOptions } = require('../util/validate');
const { findRecursively } = require('../util/find');
/**
* Reaction Controller class
*/
class Controller {
/**
* Reaction Controller constructor
* @param {Message} botMessage - Message where reaction collector is working.
* @param {Discord.ReactionCollector} collector - Collector from botMessage.
* @param {Object} pages - All reaction collector pages.
* @return {Controller}
*/
constructor(botMessage, collector, pages) {
this._botMessage = botMessage;
this._collector = collector;
this._pages = pages;
this._lastPage = null;
this._currentPage = null;
}
/**
* Stop all collectors funcion
* @param {string?} [reason='user'] - The reason this collector is ending
* @return {void}
*/
stop(reason = 'user') {
if (this.messagesCollector) this.messagesCollector.stop(reason);
return this._collector.stop(reason);
}
/**
* Reset collectors timer
* @param {Object} [options] -
* @param {number} [options.time] - How long to run the collector for in milliseconds.
* @param {number} [options.idle] -How long to stop the collector after inactivity in milliseconds.
* @return {void}
*/
resetTimer(options) {
if (this.messagesCollector) this.messagesCollector.resetTimer(options);
this.collector.resetTimer(options);
}
/**
* Go to other page
* @param {string|number} pageId - Specific ID to other page.
* @throws {string} Invalid action if page id given doesn't exists.
* @return {Promise<void>}
*/
async goTo(pageId) {
const pages = [];
findRecursively({
obj: this.pages,
key: 'id',
value: pageId,
type: 'object',
result: pages,
});
const page = pages.shift();
if (!page) return Promise.reject(new Error(`Invalid action: Couldn't go to page '${pageId}', this page doens't exists.`));
this.currentPage = page;
await this.update();
}
/**
* Back to last page
* @throws {string} - Invalid action if tou cannot back without a last page valid.
* @return {Promise<void>}
*/
async back() {
if (!this.canBack) {
return Promise.reject(
new Error(
'Invalid action: Cannot back without last page valid.',
),
);
}
const aux = this.currentPage;
this.currentPage = this.lastPage;
this.lastPage = aux;
return this.update();
}
/**
* Update botMessage when page was changed.
* @param {boolean} [onlyMessage=false] - Do you need update only message, without reactions? Default false.
* @return {Promise<void>}
*/
async update(onlyMessage = false) {
if (onlyMessage) return this.botMessage.edit(this.currentPage);
await this.botMessage.edit(this.currentPage);
await this.botMessage.reactions.removeAll();
if (this.currentPage.clearReactions) {
await this.botMessage.reactions.removeAll();
} else if (this.currentPage.reactions) {
await Promise.all(
this.currentPage.reactions.map((r) => this.botMessage.react(r)),
);
}
if (this.currentPage.backEmoji) await this.botMessage.react(this.currentPage.backEmoji);
}
/**
* Bot message of reaction collector.
* @type {Message}
* @readonly
*/
get botMessage() {
return this._botMessage;
}
/**
* Last page visualized by user.
* @type {Object?}
* @readonly
*/
get lastPage() {
return this._lastPage;
}
set messagesCollector(value) {
this._messagesCollector = value;
}
/**
* Discord.js message collector, if pages have funcion to catch messages.
* @type {Discord.MessageCollector?}
* @readonly
*/
get messagesCollector() {
return this._messagesCollector;
}
/**
* Discord.js reaction collector
* @type {Discord.ReactionCollector}
* @readonly
*/
get collector() {
return this._collector;
}
/**
* Current page.
* @type {Object}
* @readonly
*/
get currentPage() {
return this._currentPage;
}
set currentPage(value) {
this.lastPage = this.currentPage || value;
this._currentPage = value;
}
set lastPage(value) {
this._lastPage = value;
}
/**
* All pages Object
* @type {Object}
* @readonly
*/
get pages() {
return this._pages;
}
/**
* Can use funcion back()?
* @type {boolean}
* @readonly
*/
get canBack() {
return this.lastPage != null;
}
}
/**
* Reaction Collector class
*/
class ReactionCollector {
/**
* Create a reaction menu. See example in {@link https://github.com/IDjinn/Discord.js-Collector/blob/master/examples/reaction-collector/menu.js}
* @param {Object} options - Options to create a reaction menu.
* @param {Message} options.botMessage - Bot message where collector will start work.
* @param {Object} options.pages - Reaction menu pages.
* @param {UserResolvable} options.user - User who can react this menu.
* @param {Discord.ReactionCollectorOptions} [options.collectorOptions] - Options to create discord.js reaction collector options.
* @param {...*} [args] - Arguments given when onReact or onMessage function was triggered.
* @return {Controller}
*/
static async menu(options, ...args) {
const {
botMessage, user, pages, collectorOptions,
} = validateOptions(
options,
'reactMenu',
);
const keys = Object.keys(pages);
const allReactions = findRecursively({
obj: pages,
key: 'reactions',
result: keys,
type: 'array',
});
findRecursively({
obj: pages,
key: 'backEmoji',
result: allReactions,
type: 'value',
});
const needCollectMessages = findRecursively({ obj: pages, key: 'onMessage' }).length > 0;
const filter = (r, u) => u.id === user.id
&& (allReactions.includes(r.emoji.id)
|| allReactions.includes(r.emoji.name))
&& !user.bot;
const collector = botMessage.createReactionCollector(
filter,
collectorOptions,
);
const controller = new Controller(botMessage, collector, pages);
collector.on('collect', async (reaction) => {
const emoji = reaction.emoji.id || reaction.emoji.name;
if (
controller.currentPage
&& emoji === controller.currentPage.backEmoji
&& controller.canBack
) {
controller.back();
return;
}
controller.currentPage = controller.currentPage && controller.currentPage.pages
? controller.currentPage.pages[emoji]
: pages[emoji];
if (controller.currentPage) {
if (typeof controller.currentPage.onReact === 'function') {
await controller.currentPage.onReact(
controller,
reaction,
...args,
);
}
}
await controller.update();
await reaction.users.remove(user.id);
});
await Promise.all(Object.keys(pages).map((r) => botMessage.react(r)));
collector.on('end', async () => botMessage.reactions.removeAll());
if (needCollectMessages) {
const messagesCollector = botMessage.channel.createMessageCollector(
(message) => message.author.id === user.id,
collectorOptions,
);
controller.messagesCollector = messagesCollector;
messagesCollector.on('collect', async (message) => {
if (message.deletable) await message.delete();
if (
controller.currentPage && typeof controller.currentPage.onMessage === 'function'
) {
await controller.currentPage.onMessage(
controller,
message,
...args,
);
}
});
collector.on('end', () => messagesCollector.stop());
}
return controller;
}
/**
* @description This method can be used to create easier react pagination, with multiple embeds pages.
* @sumary {Function[]?} options.onReact cannot be set in this method. (yet)
* See full example in {@link https://github.com/IDjinn/Discord.js-Collector/blob/master/examples/reaction-collector/paginator.js}
* @param {PaginatorOptions} options
* @param {Message} options.botMessage - Message from Bot to create reaction collector.
* @param {UserResolvable} options.user - UserResolvable who will react.
* @param {MessageEmbed[]} options.pages - Array with embeds.
* @param {EmojiResolvable[]} [options.reactions] - Array with back/skip reactions.
* @param {Discord.ReactionCollectorOptions?} [options.collectorOptions] - Default discord.js reaction collector options
* @param {boolean?} [options.deleteReaction=true] - The Bot will remove reaction after user react?
* @param {boolean?} [options.deleteAllOnEnd=true] - The Bot will remove reaction after collector end?
* @example
* const botMessage = await message.channel.send('Simple paginator...');
* ReactionCollector.paginator({
* botMessage,
* user: message.author,
* pages: [
* new MessageEmbed({ description: 'First page content...' }),
* new MessageEmbed({ description: 'Second page content...' })
* ]
* });
* @returns void
*/
static async paginator(options) {
const {
botMessage,
user,
pages,
collectorOptions,
reactionsMap,
deleteReaction,
deleteAllOnEnd,
} = validateOptions(options, 'reactPaginator');
if (!pages || pages.length === 0) return Promise.reject(new Error('Invalid input: pages is null or empty'));
pages.index = 0;
await botMessage.edit({ embed: pages[pages.index] });
const collector = this.__createReactionCollector(
{
botMessage,
user,
reactionsMap,
collectorOptions,
deleteReaction,
deleteAllOnEnd,
},
botMessage,
pages,
);
return collector;
}
/**
* @description This method can be used in multiples emoji choices.
* @param {CollectorOptions} options
* @param {Message} options.botMessage - Message from Bot to create reaction collector.
* @param {UserResolvable} options.user - UserResolvable who will react.
* @param {EmojiResolvable[]} [options.reactions=['✅','❌']] - Object with reactions and functions.
* @param {Discord.ReactionCollectorOptions?} [options.collectorOptions] - Default discord.js reaction collector options
* @param {boolean?} [options.deleteReaction=true] - The Bot will remove reaction after user react?
* @param {boolean?} [options.deleteAllOnEnd=true] - The Bot will remove reaction after collector end?
* @param {...*} [args] - All args given at trigger onReact() funcion.
* See example in {@link https://github.com/IDjinn/Discord.js-Collector/tree/master/examples/reaction-collector/question.js}
* @note onReact(reation, ...args) = When user react, will trigger this function
* @returns Discord.ReactionCollector
*/
static question(options, ...args) {
return this.__createReactionCollector(
validateOptions(options, 'reactQuestion'),
...args,
);
}
/**
* @description This method can be used in async methods, returning only boolean value, more easier to use inside if tratament or two choices.
* @summary See full example in {@link https://github.com/IDjinn/Discord.js-Collector/blob/master/examples/reaction-collector/yesNoQuestion.js}
* @param {AsyncCollectorOptions} options
* @param {Message} options.botMessage - Message from Bot to create reaction collector.
* @param {UserResolvable} options.user - UserResolvable who will react.
* @param {EmojiResolvable[]} [options.reactions=['✅','❌']] - Array with 2 emojis, first one is "Yes" and second one is "No".
* @param {Discord.ReactionCollectorOptions} [options.collectorOptions] - Default discord.js reaction collector options
* @param {boolean} [options.deleteReaction=true] - The Bot will remove reaction after user react?
* @param {boolean} [options.deleteAllOnEnd=true] - The Bot will remove reaction after collector end?
* @returns {Promise<boolean>}
*
* @example
* const botMessage = await message.channel.send('Simple yes/no question');
* if (await ReactionCollector.yesNoQuestion({ user: message.author, botMessage }))
* message.channel.send('You\'ve clicked in yes button!');
* else
* message.channel.send('You\'ve clicked in no button!');
*/
static async yesNoQuestion(options) {
return this.__createYesNoReactionCollector(
validateOptions(options, 'yesNoQuestion'),
);
}
/**
* @description Internal methods, do not use.
* @private
* @param {CollectorOptions} _options
* @returns {Discord.ReactionCollector}
*/
static async __createReactionCollector(_options, ...args) {
const {
botMessage,
reactionsMap,
user,
collectorOptions,
deleteReaction,
deleteAllOnEnd,
} = _options;
const reactions = Object.keys(reactionsMap) || reactionsMap;
await Promise.all(reactions.map((r) => botMessage.react(r)));
const filter = (r, u) => u.id === user.id
&& (reactions.includes(r.emoji.id)
|| reactions.includes(r.emoji.name))
&& !user.bot;
const collector = botMessage.createReactionCollector(
filter,
collectorOptions,
);
collector.on('collect', async (reaction) => {
const emoji = reaction.emoji.id || reaction.emoji.name;
if (deleteReaction) await reaction.users.remove(user.id);
if (typeof reactionsMap[emoji] === 'function') reactionsMap[emoji](reaction, collector, ...args);
});
if (deleteAllOnEnd) {
collector.on(
'end',
async () => botMessage.reactions.removeAll(),
);
}
return collector;
}
/**
* @description Internal methods, do not use.
* @private
* @param {AsyncCollectorOptions} _options
* @returns {Promise<boolean>}
*/
static async __createYesNoReactionCollector(_options) {
return new Promise(async (resolve) => {
const {
botMessage,
reactionsMap,
user,
collectorOptions,
deleteReaction,
deleteAllOnEnd,
} = _options;
const reactions = Object.keys(reactionsMap) || reactionsMap;
await Promise.all(reactions.map((r) => botMessage.react(r)));
const filter = (r, u) => u.id === user.id
&& (reactions.includes(r.emoji.id)
|| reactions.includes(r.emoji.name))
&& !user.bot;
const caughtReactions = await botMessage.awaitReactions(
filter,
collectorOptions,
);
if (caughtReactions.size > 0) {
const reactionCollected = caughtReactions.first();
if (deleteAllOnEnd) await reactionCollected.message.reactions.removeAll();
else if (deleteReaction) await reactionCollected.users.remove(user.id);
return resolve(
reactions.indexOf(
reactionCollected.emoji
? reactionCollected.emoji.name
|| reactionCollected.emoji.id
: reactionCollected.name || reactionCollected.id,
) === 0,
);
}
return resolve(false);
});
}
}
module.exports = {
Controller,
ReactionCollector,
};