/* eslint-disable no-console */
/* eslint-disable no-plusplus */
/* eslint-disable no-param-reassign */
const {
Client,
Role,
Message,
Collection,
GuildMember,
Util,
MessageReaction,
User,
} = require('discord.js');
const { EventEmitter } = require('events');
const fs = require('fs');
const AsyncLock = require('async-lock');
const Constants = require('../util/constants');
const { ReactionRole, IRequirementType } = require('./reactionRole');
const {
ReactionRoleEvent, ReactionRoleType, RequirementType, ActionType, isValidReactionRoleType,
} = require('./constants');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const locker = new AsyncLock();
/**
* Example in {@link https://github.com/IDjinn/Discord.js-Collector/blob/master/examples/reaction-role-manager/basic.js}
* @extends EventEmitter
*/
class ReactionRoleManager extends EventEmitter {
/**
* Triggered when reaction role manager is ready.
* @event ReactionRoleManager#ready
* @example
* reactionRoleManager.on('ready', () => {
* console.log('Reaction Role Manager is ready!');
* });
*/
/**
* Triggered for debug messages.
* @event ReactionRoleManager#debug
* @example
* reactionRoleManager.on('debug', (message) => {
* console.log(message);
* });
*/
/**
* Triggered when member won a reaction role.
* @event ReactionRoleManager#reactionRoleAdd
* @property {GuildMember} member - The guild member who won the role.
* @property {Role} role - The guild role what member was won.
* @example
* reactionRoleManager.on('reactionRoleAdd', (member, role) => {
* console.log(member.displayName + ' won the role ' + role.name)
* });
*/
/**
* Triggered when member lose a reaction role.
* @event ReactionRoleManager#reactionRoleRemove
* @property {GuildMember} member - The guild member who lost the role.
* @property {Role} role - The guild role what member was lost.
*
* @example
* reactionRoleManager.on('reactionRoleRemove', (member, role) => {
* console.log(member.displayName + ' lose the role ' + role.name)
* });
*/
/**
* Triggered when someone remove reactions from a message.
* @event ReactionRoleManager#allReactionsRemove
* @property {Message} message - The message what reaction was removed.
* @property {Role[]} rolesAffected - Roles affected when reactions was removed.
* @property {GuildMember[]} membersAffected - Members affected when reactions was removed.
* @property {number} reactionsTaken - Count of reactions removed from message.
*
* @example
* reactionRoleManager.on('allReactionsRemove', (message) => {
* console.log(`All reactions from message ${message.id} was removed, all roles was taken and reactions roles deleted.`)
* });
*/
/**
* Triggered when someone tried won role, but not have it requirements.
* @event ReactionRoleManager#missingRequirements
* @property {RequirementType} requirementType - The missing requirement to win this role.
* @property {GuildMember} member - Member who will not win this role.
* @property {ReactionRole} reactionRole - This reaction role what the member hasn't the requirements.
* @property {object?} requirementsMissing - All things missing to win this role (e.g roles)
*
* @example
* reactionRoleManager.on('missingRequirements', (type, member, reactionRole) => {
* console.log(`Member '${member.id}' will not win the roles '${reactionRole.roles}', because him hasn't requirement ${type}`);
* });
*/
/**
* Create your custom hooks to execute before/after Reaction Role Manager do things.
* @summary Pay attention: return value must be boolean! If is not, will not work like you wish.
* @typedef {Object} IHooks
* @property {Promise<boolean>} preRoleAddHook - Function executed before add a role to some member.
* If return value is false, this action will be bypassed.
* @property {Promise<boolean>} preRoleRemoveHook - Function executed before remove a role from some member.
* If return value is false, this action will be bypassed.
*/
/**
* Triggered when the bot doesn't have permissions to manage this role.
* @sumary Warning: Each role will be emitted only once per member.
* If it react again and bot cannot give role to it, will not emit the role was previously emitted.
* @event ReactionRoleManager#missingPermissions
* @property {ActionType} action - Is this action to give or to take off these roles?
* @property {GuildMember} member - Member who will not win/lose the role.
* @property {Role[]} roles - Roles what bot cannot manage to give/take to member.
* @property {ReactionRole} reactionRole - Reaction Role what will not given/taken from member.
* @example
* reactionRoleManager.on('missingPermissions', (action, member, roles, reactionRole) => {
* console.log(`Some roles cannot be ${action === 1 ? 'given' : 'taken'} to member \`${member.displayName}\`,
* because i don't have permissions to manage these roles: ${roles.map(role => `\`${role.name}\``).join(',')}`);
* });
*/
/**
* Reaction Role Manager constructor
* @param {Client} client - Discord js client Object.
* @param {Object} [options] -
* @param {boolean} [options.storage=true] - Enable/disable storage of reaction role.
* @param {string} [options.mongoDbLink=null] - Link to connect with mongodb.
* @param {string} [options.path=null] - Path to save json data of reactions roles.
* @param {boolean} [options.debug=false] - Enable/Disable debug of reaction role manager.
* @param {IHooks} [options.hooks={}] - Custom hooks to execute before do things.
* @param {boolean} [options.keepReactions] - Keep reactions if some reaction roles was deleted.
* @extends EventEmitter
* @return {ReactionRoleManager}
*/
constructor(
client,
{
storage, mongoDbLink, path, disabledProperty, hooks, keepReactions,
},
) {
super();
/**
* Is Reaction role manager ready?
* @type {boolean}
* @readonly
*/
this.isReady = false;
/**
* Reaction role manager ready date
* @type {Date?}
*/
this.readyAt = null;
/**
* Discord client.
* @type {Client}
* @readonly
*/
this.client = client;
/**
* Is storage enabled?
* @type {boolean}
* @default true
*/
this.storage = typeof storage === 'boolean' ? storage : true;
/**
* Mongo db connection link.
* @type {string?}
* @readonly
*/
this.mongoDbLink = mongoDbLink || null;
/**
* ReactionRoles collection
* @type {Collection<string, ReactionRole>}
* @readonly
*/
this.reactionRoles = new Collection();
/**
* Timeouts to check toggled roles collection - Internal use.
* @type {Collection<string, Function>}
* @readonly
*/
this.timeouts = new Collection();
/**
* Json storage path
* @type {string?}
*/
this.storageJsonPath = path || null;
/**
* Disable RR instead delete?
* @default true
* @type {boolean}
*/
this.disabledProperty = typeof disabledProperty === 'boolean' ? disabledProperty : true;
/**
* Define hooks for executed while Reaction Role Manager is running.
* @type {IHooks}
*/
this.hooks = {
preRoleAddHook: (...args) => true,
preRoleRemoveHook: (...args) => true,
...hooks,
};
/**
* Keep reactions if some reaction role is deleted.
* @type {boolean}
*/
this.keepReactions = typeof keepReactions === 'boolean' ? keepReactions : false;
/**
* Set with already warned unmanaged permission roles.
* @private
* @type {Set<string>}
*/
this.__withoutPermissionsWarned = new Set();
if (this.hooks.preRoleAddHook && typeof this.hooks.preRoleAddHook !== 'function') throw new Error('Hook \'preRoleAdd\' must be a function.');
else if (this.hooks.preRoleRemoveHook && typeof this.hooks.preRoleRemoveHook !== 'function') {
throw new Error('Hook \'preRoleRemoveHook\' must be a function.');
}
this.client.on('ready', () => this.__resfreshOnBoot());
this.client.on('messageReactionAdd', (msgReaction, user) => this.__onReactionAdd(msgReaction, user));
this.client.on('messageReactionRemove', (msgReaction, user) => this.__onReactionRemove(msgReaction, user));
this.client.on('messageReactionRemoveAll', (message) => this.__onRemoveAllReaction(message));
this.client.on('roleDelete', async (role) => {
const reactionRole = this.reactionRoles.find((rr) => rr.roles.includes(role.id));
if (reactionRole) return this.__handleDeleted(reactionRole, role);
});
this.client.on('emojiDelete', async (emoji) => {
const emojiIdentifier = this.__resolveReactionEmoji(emoji);
const reactionRole = this.reactionRoles.find((rr) => rr.emoji === emojiIdentifier);
if (reactionRole) return this.__handleDeleted(reactionRole, emoji);
});
this.client.on('guildDelete', async (guild) => {
const reactionRole = this.reactionRoles.find((rr) => rr.guild === guild.id);
if (reactionRole) return this.__handleDeleted(reactionRole, guild);
});
this.client.on('channelDelete', async (channel) => {
const reactionRole = this.reactionRoles.find((rr) => rr.channel === channel.id);
if (reactionRole) return this.__handleDeleted(reactionRole, channel);
});
const messageDeleteHandler = async (message) => {
const reactionRole = this.reactionRoles.find((rr) => rr.message === message.id);
if (reactionRole) return this.__handleDeleted(reactionRole, message);
};
this.client.on('messageDelete', (msg) => messageDeleteHandler(msg));
this.client.on('messageDeleteBulk', (messages) => {
const array = messages.array();
for (let i = 0; i < array.length; i += 1) {
messageDeleteHandler(array[i]);
}
});
}
/**
* Handle some delete event, and resolve delete reaction role.
* @private
* @param {ReactionRole} reactionRole - Reaction Role to delete.
* @param {GuildResolvable} guildResolvable - Guild where need delete reaction role.
* @return {Promise<void>}
*/
async __handleDeleted(reactionRole, guildResolvable, callback = () => this.deleteReactionRole({ reactionRole }, true)) {
if (this.keepReactions) return callback();
const guild = this.client.guilds.resolve(guildResolvable);
if (!guild) return callback();
const channel = guild.channels.cache.get(reactionRole.channel);
if (!channel) return callback();
const message = await channel.messages.fetch(reactionRole.message);
if (!message) return callback();
const reaction = message.reactions.cache.find((x) => reactionRole.id === `${message.id}-${this.__resolveReactionEmoji(x.emoji)}`);
if (!reaction) return callback();
await reaction.remove();
}
/**
* Check and setup mongoose, if it is enabled.
* @private
* @return {Promise<void>}
*/
async __checkMongoose() {
return new Promise(async (resolve, reject) => {
if (!this.mongoDbLink) return resolve('Mongoose is disabled.');
try {
this.mongoose = require('mongoose');
await this.mongoose.connect(this.mongoDbLink, {
useNewUrlParser: true,
useUnifiedTopology: true,
useFindAndModify: false,
});
this.mongoose.model(
'ReactionRoles',
new this.mongoose.Schema({
id: String,
message: String,
channel: String,
guild: String,
role: String,
emoji: String,
winners: Array,
max: {
type: Number,
default: 0,
},
toggle: {
type: Boolean,
},
requirements: {
boost: {
type: Boolean,
default: false,
},
verifiedDeveloper: {
type: Boolean,
default: false,
},
},
disabled: {
type: Boolean,
default: false,
},
type: {
type: Number,
default: 0,
},
roles: {
type: Array,
default: [],
},
}),
);
return resolve(true);
} catch (e) {
return reject(e);
}
});
}
/**
* Startup reaction roles from storage on ready event (database/json).
* @private
* @return {Promise<void>}
*/
async __resfreshOnBoot() {
if (!this.storage) return;
await this.__checkMongoose();
await this.__parseStorage();
await sleep(1500);
const reactionRoleArray = this.reactionRoles.array();
for (let i = 0; i < reactionRoleArray.length; i += 1) {
const reactionRole = reactionRoleArray[i];
const guild = this.client.guilds.cache.get(reactionRole.guild);
if (!guild) {
this.__debug(
'BOOT',
`Role '${reactionRole.id}' failed at start, guild wasn't found.`,
);
this.__handleDeleted(reactionRole, guild);
continue;
}
const channel = guild.channels.cache.get(reactionRole.channel);
if (!channel) {
this.__debug(
'BOOT',
`Role '${reactionRole.id}' failed at start, channel wasn't found.`,
);
this.__handleDeleted(reactionRole, guild);
continue;
}
try {
const message = await channel.messages.fetch(reactionRole.message).catch(() => null);
if (!message || !(message instanceof Message)) continue;
if (message.partial) await message.fetch();
if (!message.reactions.cache.has(reactionRole.emoji)) await message.react(reactionRole.emoji);
const reaction = message.reactions.cache.find(
(x) => reactionRole.id === `${message.id}-${this.__resolveReactionEmoji(x.emoji)}`,
);
if (reaction.partial) await reaction.fetch();
const users = await reaction.users.fetch();
const usersArray = users.array();
for (let j = 0; j < usersArray.length; j += 1) {
const user = usersArray[j];
if (user.partial) await user.fetch();
if (user.bot) continue;// Ignore bots, please!
const member = guild.members.cache.get(user.id);
if (!member) {
await reaction.users.remove(user.id);
this.__debug(
'BOOT',
`Member '${user.id}' wasn't found, reaction of his was removed from message.`,
);
continue;
}
await this.__handleReactionRoleAction(ActionType.GIVE, member, reactionRole, reaction);
}
for (let j = 0; j < reactionRole.winners.length; j += 1) {
const winnerId = reactionRole.winners[j];
const member = guild.members.cache.get(winnerId);
if (!member) {
reactionRole.winners.splice(j, 1);
this.__debug(
'BOOT',
`Member '${winnerId}' wasn't found, his was removed from winner list.`,
);
continue;
}
if (member.partial) await member.fetch();
if (member.user.partial) await member.fetch();
if (member.user.bot) continue;
if (!users.has(winnerId)) await this.__handleReactionRoleAction(ActionType.TAKE, member, reactionRole, reaction);
}
} catch (error) {
if (error && error.code === 10008) {
this.__debug(
'BOOT',
`Role '${reactionRole.id}' failed at start, message wasn't found.`,
);
this.__handleDeleted(reactionRole, guild);
continue;
}
throw error;
}
this.__readyTimeout();
}
}
/**
* @private
* @param {string} type
* @param {string} message
* @param {...*} args
* @return {void}
*/
__debug(type, message, ...args) {
this.emit(ReactionRoleEvent.DEBUG, `[${new Date().toLocaleString()}] [REACTION ROLE] [DEBUG] [${type.toUpperCase()}] - ${message} ${args}`);
}
/**
* Check if members have all requirements and handle if it doesn't have it.
* @private
* @param {ReactionRole} reactionRole - Reaction role to check requirements.
* @param {MessageReaction} reaction - Message reaction to remove reaction if it dosn't have requirements.
* @param {GuildMember} member - Member to check requirements.
* @return {Promise<boolean>}
*/
async __checkRequirements(reactionRole, reaction, member) {
return new Promise(async (resolve) => {
if (reactionRole.requirements.permissionsNeed.length > 0) {
const missingPermissions = member.permissions.missing(reactionRole.requirements.permissionsNeed);
if (missingPermissions.length > 0) {
this.emit(
ReactionRoleEvent.MISSING_REQUIREMENTS,
RequirementType.PERMISSION,
member,
reactionRole,
missingPermissions,
);
await reaction.users.remove(member.user);
this.__debug(
'BOOT',
`Member '${member.id}' not have all permissions requirement, will not win this role.`,
);
return resolve(false);
}
}
if (reactionRole.requirements.roles.allowList.length > 0
|| reactionRole.requirements.roles.denyList.length > 0) {
const withoutAllowedRoles = reactionRole.requirements.roles.allowList.filter((roleId) => !member.roles.cache.has(roleId));
const withDeniedRoles = reactionRole.requirements.roles.denyList.filter((roleId) => member.roles.cache.has(roleId));
const roles = {
withoutAllowedRoles: withoutAllowedRoles.map((roleId) => member.guild.roles.resolve(roleId)),
withDeniedRoles: withDeniedRoles.map((roleId) => member.guild.roles.resolve(roleId)),
};
if (withoutAllowedRoles.length > 0 || withDeniedRoles.length > 0) {
this.emit(
ReactionRoleEvent.MISSING_REQUIREMENTS,
RequirementType.ROLES,
member,
reactionRole,
roles,
);
await reaction.users.remove(member.user);
this.__debug(
'BOOT',
`Member '${member.id}' not have all allowed roles requirement or has some denied roles, will not win this role.`,
);
return resolve(false);
}
}
if (reactionRole.requirements.users.allowList.length > 0
|| reactionRole.requirements.users.denyList.length > 0) {
const allowedUsers = reactionRole.requirements.users.allowList.map((userId) => member.guild.members.resolve(userId));
const deniedUsers = reactionRole.requirements.users.denyList.map((userId) => member.guild.members.resolve(userId));
const users = { allowedUsers, deniedUsers };
/* eslint-disable no-shadow */
if ((allowedUsers.length > 0 && !allowedUsers.find((member) => member.id))
|| (deniedUsers.length > 0 && deniedUsers.find((member) => member.id))) {
this.emit(
ReactionRoleEvent.MISSING_REQUIREMENTS,
RequirementType.USERS,
member,
reactionRole,
users,
);
await reaction.users.remove(member.user);
this.__debug(
'BOOT',
`Member '${member.id}' not have all allowed users requirement or has included in some denied users list, will not win this role.`,
);
return resolve(false);
}
/* eslint-enable no-shadow */
}
if (!reactionRole.checkBoostRequirement(member)) {
this.emit(
ReactionRoleEvent.MISSING_REQUIREMENTS,
RequirementType.BOOST,
member,
reactionRole,
);
await reaction.users.remove(member.user);
this.__debug(
'BOOT',
`Member '${member.id}' not have boost requirement, will not win this role.`,
);
return resolve(false);
}
if (
!(await reactionRole.checkDeveloperRequirement(member))
) {
this.emit(
ReactionRoleEvent.MISSING_REQUIREMENTS,
RequirementType.VERIFIED_DEVELOPER,
member,
reactionRole,
);
await reaction.users.remove(member.user);
this.__debug(
'BOOT',
`Member '${member.user.id}' not have verified developer requirement, will not win this role.`,
);
return resolve(false);
}
return resolve(true);
});
}
/**
* Create new reaction role.
* @param {Object} options - Object with options to create new reaction role.
* @param {Message} options.message - Message what will have the reactions.
* @param {Role[]} options.roles - Roles what the bot will give/take from members when they react.
* @param {Emoji} options.emoji - Emoji or emoji id what member will react to win/lose the role.
* @param {ReactionRoleType} [options.type=1] - Type of reaction role.
* @param {Number} [options.max=0] - Max roles to give. If it's 0, will not have a limit.
* @param {IRequirementType} [options.requirements] - Requirements to win this role.
* @param {boolean} [options.requirements.boost=false] - Need be a booster to win this role?
* @param {boolean} [options.requirements.verifiedDeveloper=false] - Need be a verified developer to win this role?
*
* @return {Promise<ReactionRole>}
* @example
* // update your import, add ReactionRoleType.
* const { ReactionRoleManager, ReactionRoleType} = require('discord.js-collector');
*
* const role = message.mentions.roles.first();
* if (!role) return message.reply('You need mention a role').then(m => m.delete({ timeout: 1000 }));
*
* const emoji = args[1];
* if (!emoji) return message.reply('You need use a valid emoji.').then(m => m.delete({ timeout: 1000 }));
*
* const msg = await message.channel.messages.fetch(args[2] || message.id);
* if (!role) return message.reply('Message not found! Wtf...').then(m => m.delete({ timeout: 1000 }));
*
* reactionRoleManager.createReactionRole({
* message: msg,
* roles: [role],
* emoji,
* type: ReactionRoleType.NORMAL // It's optional, normal by default
* });
*/
createReactionRole(
{
message, roles, emoji, type, max, requirements,
} = {
requirements: { boost: false, verifiedDeveloper: false },
},
) {
return new Promise(async (resolve, reject) => {
if (message instanceof Message) {
if (!message.guild) {
return reject(
new Error('Bad input: message must be a guild message, cannot create reaction role in DM channels.'),
);
}
if (type && !isValidReactionRoleType(type)) return reject(new Error(`Bad input: Invalid reaction role type: '${type}'.`));
if (!type) type = ReactionRoleType.NORMAL;
if (!max || max > Number.MAX_SAFE_INTEGER || max < 0) max = Number.MAX_SAFE_INTEGER;
roles = roles.map((role) => message.guild.roles.resolveID(role)).filter((role) => role);
if (!roles || roles.length === 0) return reject(new Error(`Bad input: I canno't resolve the roles ${roles}`));
const emojiParsed = Util.parseEmoji(emoji);
emoji = this.__resolveReactionEmoji(emojiParsed);
if (!emoji) return reject(new Error(`Bad input: I canno't resolve emoji ${emoji}`));
if (emojiParsed
&& emojiParsed.id
&& !this.client.emojis.resolve(emojiParsed.id)
) return reject(new Error(`Bad input: I canno't find emoji ${emoji}`));
await message.react(emoji);
const reactionRole = new ReactionRole({
message,
roles,
emoji,
type,
max,
requirements,
});
this.reactionRoles.set(reactionRole.id, reactionRole);
await this.store(reactionRole);
this.__debug(
'ROLE',
`Roles '[${roles}]' added in reactionRoleManager!`,
);
return resolve(reactionRole);
}
return reject(new Error('Bad input: addRole({...}) message must be a Message Object.'));
});
}
/**
* This funcion will delete the reaction role from storage.
* @param {object} options -
* @param {object} [options.reactionRole] - Reaction Role to delete
* @param {object} [options.message] - Message of Reaction Role. If you want delete it and not have the reaction role object
* @param {object} [options.emoji] - Emoji of Reaction Role. If you want delete it and not have the reaction role object
* @param {boolean} [deleted=false] - Is role deleted from guild?
* @return {Promise<ReactionRole?>}
*
* @example
* const emoji = args[0];
* if (!emoji) return message.reply('You need use a valid emoji.').then(m => m.delete({ timeout: 1000 }));
*
* const msg = await message.channel.messages.fetch(args[1]);
* if (!msg) return message.reply('Message not found! Wtf...').then(m => m.delete({ timeout: 1000 }));
*
* await reactionRoleManager.deleteReactionRole({message: msg, emoji});
*/
async deleteReactionRole({ reactionRole, message, emoji }, deleted = false) {
return new Promise(async (resolve, reject) => {
if (message && emoji) {
const resolvedEmojiID = this.__resolveReactionEmoji(Util.parseEmoji(emoji));
const messageID = message && message.id ? message.id : message;
if (!messageID) return reject(new Error('Bad input: invalid message param type, must be instance of Message.'));
reactionRole = this.reactionRoles.find((rr) => rr.message === messageID && rr.emoji === resolvedEmojiID);
if (!(reactionRole instanceof ReactionRole)) {
return reject(
new Error(
`Bad input: I cannot find any reaction role with message ID '${messageID}' and emoji '${emoji}'`,
),
);
}
}
if (reactionRole instanceof ReactionRole) {
if (!this.keepReactions) await this.__handleDeleted(reactionRole, reactionRole.guild, () => { });
reactionRole.disabled = true;
if (this.disabledProperty) await this.store(reactionRole);
// eslint-disable-next-line curly
else if (this.mongoose) await this.mongoose
.model('ReactionRoles')
.deleteOne({ id: reactionRole.id })
.exec();
else this.reactionRoles.delete(reactionRole.id);
if (deleted) {
this.__debug(
'ROLE',
`Role '${reactionRole.id}' deleted, so it was removed from reactionRoleManager!`,
);
} else {
this.__debug(
'ROLE',
`Role '${reactionRole.id}' removed from reactionRoleManager!`,
);
}
return resolve(reactionRole && reactionRole.disabled ? reactionRole : null);
}
return reject(new Error('Bad input: deleteReactionRole(role) must be a ReactionRole Object.'));
});
}
/**
* Store updated roles funcion. Note: for json storage, doesn't need give arguments to this funcion.
* @param {...ReactionRole} roles - All roles to update in database.
* @return {Promise<void>}
*/
async store(...roles) {
return new Promise(async (resolve) => {
if (this.storage) {
if (this.mongoose) {
for (let i = 0; i < roles.length; i += 1) {
const role = roles[i];
await this.mongoose
.model('ReactionRoles')
.findOneAndUpdate({ id: role.id }, role, {
new: true,
upsert: true,
})
.exec();
}
this.__debug(
'STORE',
`Stored ${roles.length} updated roles.`,
);
}
if (this.storageJsonPath) {
fs.writeFileSync(
this.storageJsonPath,
JSON.stringify(
this.reactionRoles.map((role) => role.toJSON()),
),
);
this.__debug(
'STORE',
`Stored roles saved, contains '${this.reactionRoles.size}' roles.`,
);
}
}
return resolve();
});
}
/**
* Parse storage roles funcion.
* @private
* @return {Promise<void>}
*/
async __parseStorage() {
return new Promise(async (resolve) => {
if (this.storage) {
const roles = [];
if (fs.existsSync(this.storageJsonPath)) {
const json = JSON.parse(
fs.readFileSync(this.storageJsonPath).toString(),
);
roles.push(...json);
}
if (this.mongoose) {
roles.push(
...(await this.mongoose
.model('ReactionRoles')
.find({ disabled: false })),
);
}
for (let i = 0; i < roles.length; i += 1) {
const role = roles[i];
if (!role || !role.message || role.disabled) continue;
this.reactionRoles.set(
role.id,
ReactionRole.fromJSON(role),
);
}
}
this.__debug(
'STORE',
`Stored roles parsed, contains '${this.reactionRoles.size}' roles.`,
);
return resolve();
});
}
/**
* Reaction Role add reaction hanlder
* @private
* @param {MessageReaction} msgReaction
* @param {User} user
* @return {Promise<void>}
*/
async __onReactionAdd(msgReaction, user) {
if (user.bot) return;
if (msgReaction.partial) await msgReaction.fetch();
if (user.partial) await user.fetch();
const emoji = this.__resolveReactionEmoji(msgReaction.emoji);
const { message } = msgReaction;
if (message.partial) await message.fetch();
const { guild } = message;
const id = `${message.id}-${emoji}`;
const member = guild.members.cache.get(user.id);
if (!member) return;
if (member.partial) await member.fetch();
const reactionRole = this.reactionRoles.get(id);
if (!(reactionRole instanceof ReactionRole)) return;
this.__handleReactionRoleAction(ActionType.GIVE, member, reactionRole, msgReaction);
}
/**
* Timeout handler to check toggled roles.
* @param {GuildMember} member
* @param {Message} message
* @param {ReactionRole} [skippedRole=null]
* @param {number} [tries=0]
* @private
* @return {Promise<void>}
*/
async __timeoutToggledRoles(member, message, skippedRole = null, tries = 0) {
if (++tries > 3) return this.__debug('TOGGLE', `Toggled roles timeout expired tries, member '${member.id}' will not be processed.`);
if (locker.isBusy(member.id)) {
this.__debug('TOGGLE', `Member '${member.id}' is holding timeout queue.`);
await sleep(Constants.DEFAULT_TIMEOUT_TOGGLED_ROLES);
return this.__timeoutToggledRoles(member, message, skippedRole, tries);
}
const timeout = this.timeouts.get(member.id);
if (timeout) this.client.clearTimeout(timeout);
this.timeouts.set(
member.id,
setTimeout(async () => locker.acquire(member.id, async () => {
const toggledRoles = this.reactionRoles.filter((rr) => rr.message === message.id && rr.isToggle);
const toggledRolesArray = toggledRoles.array();
for (let i = 0; i < toggledRolesArray.length; i += 1) {
const toggledRole = toggledRolesArray[i];
if (toggledRole.disabled) continue;
const reaction = message.reactions.cache.find(
(r) => this.__resolveReactionEmoji(r.emoji) === toggledRole.emoji,
);
if (member.partial) await member.fetch();
if (reaction.partial) await reaction.fetch();
const users = await reaction.users.fetch();
if (users.has(member.id) && (!skippedRole || skippedRole.id === toggledRole.id)) {
skippedRole = toggledRole;
continue;
}
const roleID = toggledRole.roles[0];
const role = member.guild.roles.cache.get(roleID);
this.__checkRolesPermissions(ActionType.TAKE, toggledRole, member);
if (role.editable && await this.hooks.preRoleRemoveHook(member, role, toggledRole)) {
const index = toggledRole.winners.indexOf(member.id);
if (index >= 0) toggledRole.winners.splice(index, 1);
if (member.roles.cache.has(toggledRole.id)) {
await member.roles.remove(roleID);
this.emit(
ReactionRoleEvent.REACTION_ROLE_REMOVE,
member,
role,
);
this.__debug(
'TOGGLE',
`Take off role '${roleID}' from user '${member.id}', it's a toggled role.`,
);
}
if (users.has(member.id)) await reaction.users.remove(member.user);
} else await reaction.users.remove(member.id);
}
if (skippedRole instanceof ReactionRole) {
const reaction = message.reactions.cache.find(
(r) => this.__resolveReactionEmoji(r.emoji) === skippedRole.emoji,
);
const roleID = skippedRole.roles[0];
const role = message.guild.roles.cache.get(roleID);
this.__checkRolesPermissions(ActionType.GIVE, skippedRole, member);
if (role.editable
&& await this.__checkRequirements(skippedRole, reaction, member)
&& await this.hooks.preRoleAddHook(member, role, skippedRole)
) {
if (skippedRole.winners.indexOf(member.id) <= -1) skippedRole.winners.push(member.id);
if (!member.roles.cache.has(roleID)) {
await member.roles.add(roleID);
this.emit(
ReactionRoleEvent.REACTION_ROLE_ADD,
member,
role,
);
if (this.isReady) {
this.__debug(
'TOGGLE',
`Role '${roleID}' was given to '${member.id}' after check toggle roles.`,
);
} else {
this.__debug(
'BOOT',
// eslint-disable-next-line max-len
`Role '${roleID}' was given to '${member.id}' after check toggle roles, it reacted when bot wasn't online.`,
);
}
} else {
this.__debug(
'BOOT',
// eslint-disable-next-line max-len
`Keeping role '${roleID}' after check toggle roles. The member '${member.id}' reacted and already have the role.`,
);
}
} else await reaction.users.remove(member.id);
}
await this.store(...toggledRoles);
}), Constants.DEFAULT_TIMEOUT_TOGGLED_ROLES),
);
}
__readyTimeout() {
const readyTimeout = this.timeouts.get('ready_timeout');
if (readyTimeout) this.client.clearTimeout(readyTimeout);
if (this.isReady) return;
this.timeouts.set('ready_timeout', setTimeout(() => {
this.isReady = true;
this.readyAt = new Date();
this.emit(ReactionRoleEvent.READY);
this.__debug('READY', 'Reaction role manager is ready.');
}, 5000));
}
/**
* Reaction Role remove reaction hanlder
* @private
* @param {MessageReaction} msgReaction
* @param {User} user
* @return {Promise<void>}
*/
async __onReactionRemove(msgReaction, user) {
if (user.bot) return;
if (msgReaction.partial) await msgReaction.fetch();
if (user.partial) await user.fetch();
const emoji = this.__resolveReactionEmoji(msgReaction.emoji);
const { message } = msgReaction;
if (message.partial) await message.fetch();
const { guild } = message;
const id = `${message.id}-${emoji}`;
const member = guild.members.cache.get(user.id);
if (!member) return;
if (member.partial) await member.fetch();
const reactionRole = this.reactionRoles.get(id);
if (!(reactionRole instanceof ReactionRole)) return;
this.__handleReactionRoleAction(ActionType.TAKE, member, reactionRole, msgReaction);
}
/**
* Reaction Role handler when reaction is clean up.
* @private
* @return {Promise<void>}
*/
async __onRemoveAllReaction(message) {
const messageReactionsRoles = this.reactionRoles.filter((r) => r.message === message.id).array();
const membersAffected = [];
const rolesAffected = new Collection();
let reactionsTaken = 0;
if (messageReactionsRoles.length <= 0) return; // We don't need keep running this code if no one reaction role will be deleted.
for (let i = 0; i < messageReactionsRoles.length; i += 1) {
const reactionRole = messageReactionsRoles[i];
for (let j = 0; j < reactionRole.winners.length; j += 1) {
const winnerId = reactionRole.winners[j];
/**
* @type {GuildMember?}
*/
const member = message.guild.members.cache.get(winnerId);
if (!member) continue;
if (member.partial) await member.fetch();
const rolesWithPermission = this.__checkRolesPermissions(ActionType.TAKE, reactionRole, member);
for (let k = 0; k < rolesWithPermission.length; k++) {
const role = rolesWithPermission[k];
if (await this.hooks.preRoleRemoveHook(member, role, reactionRole)) {
await member.roles.remove(role.id);
if (!membersAffected.includes(member)) membersAffected.push(member);
}
if (!rolesAffected.has(role.id)) rolesAffected.set(role.id, role);
}
reactionsTaken += 1;
}
await this.deleteReactionRole({ reactionRole }, true);
this.__debug(
'ROLE',
`Reaction role '${reactionRole.id}' was deleted, by someone take off all reactions from message.`,
);
}
this.emit(
ReactionRoleEvent.ALL_REACTIONS_REMOVE,
message,
rolesAffected,
membersAffected,
reactionsTaken,
);
}
/**
* @private
* @param {ActionType} action
* @param {GuildMember} member
* @param {ReactionRole} reactionRole
* @param {MessageReaction} msgReaction
*/
async __handleReactionRoleAction(action, member, reactionRole, msgReaction) {
if (reactionRole.disabled) return;
if (reactionRole.isReversed) action = action === ActionType.GIVE ? ActionType.TAKE : ActionType.GIVE;
if (member.partial) await member.fetch();
if (msgReaction.partial) await msgReaction.fetch();
await msgReaction.users.fetch();
if (reactionRole.isJustLose && action === ActionType.GIVE) {
await msgReaction.users.remove(member.id);
return this.__debug('ACTION',
`Member '${member.id}' will not win the reaction role '${reactionRole.id}' because this reaction role is just for lose, not for win.`);
}
if (reactionRole.isJustWin && action === ActionType.TAKE) {
return this.__debug('ACTION',
`Member '${member.id}' will not lose the reaction role '${reactionRole.id}' because this reaction role is just for win, not for lose.`);
}
const rolesWithPermission = this.__checkRolesPermissions(action, reactionRole, member);
switch (action) {
case ActionType.GIVE: {
if (reactionRole.winners.length >= reactionRole.max && reactionRole.max > 0) {
await msgReaction.users.remove(member.id);
this.__debug(
'ROLE',
`Member will not win the reaction role '${reactionRole.id}' because the maximum number of roles to give has been reached`,
);
break;
}
if (!await this.__checkRequirements(reactionRole, msgReaction, member)) break;
if (reactionRole.isToggle) {
this.__timeoutToggledRoles(member, msgReaction.message, reactionRole);
break;
}
for (let i = 0; i < rolesWithPermission.length; i++) {
const role = rolesWithPermission[i];
if (await this.hooks.preRoleAddHook(member, role, reactionRole) && !member.roles.cache.has(role.id)) {
await member.roles.add(role);
this.emit(ReactionRoleEvent.REACTION_ROLE_ADD, member, role);
this.__debug(
'ROLE',
`User '${member.displayName}' won the role '${role.name}'.`,
);
if (reactionRole.winners.indexOf(member.id) <= -1) {
reactionRole.winners.push(member.id);
this.store(reactionRole);
}
}
}
break;
}
case ActionType.TAKE: {
for (let i = 0; i < rolesWithPermission.length; i++) {
const role = rolesWithPermission[i];
if (await this.hooks.preRoleRemoveHook(member, role, reactionRole) && member.roles.cache.has(role.id)) {
await member.roles.remove(role);
this.emit(ReactionRoleEvent.REACTION_ROLE_REMOVE, member, role);
this.__debug(
'ROLE',
`User '${member.displayName}' lost the role '${role.name}'.`,
);
}
}
const index = reactionRole.winners.indexOf(member.id);
if (index >= 0) {
reactionRole.winners.splice(index, 1);
this.store(reactionRole);
}
break;
}
default: {
throw new Error(`Unknow action type: ${action}`);
}
}
}
/**
* @private
* @param {ActionType} action
* @param {ReactionRole} reactionRole
* @param {GuildMember} member
* @return {Role[]}
*/
__checkRolesPermissions(action, reactionRole, member) {
const roles = reactionRole.roles.map((role) => member.guild.roles.resolve(role)).filter((role) => role);
const rolesWithoutPermission = roles.filter((role) => !role.editable && !this.__withoutPermissionsWarned.has(`${role.id}-${member.id}`));
const rolesWithPermission = roles.filter((role) => role.editable);
if (rolesWithoutPermission.length > 0) {
for (let i = 0; i < rolesWithoutPermission.length; i++) {
const role = rolesWithoutPermission[i];
this.__withoutPermissionsWarned.add(`${role.id}-${member.id}`);
}
this.emit(
ReactionRoleEvent.MISSING_PERMISSIONS,
action,
member,
rolesWithoutPermission,
reactionRole,
);
}
return rolesWithPermission;
}
__resolveReactionEmoji(emoji) {
return emoji.id || this.client.emojis.resolveIdentifier(emoji.name);
}
}
module.exports = {
ReactionRoleManager,
ReactionRole,
REQUIREMENT_TYPE: RequirementType,
REACTIONROLE_EVENT: ReactionRoleEvent,
};