// Copyright 2018-2020 Campbell Crowley. All rights reserved. // Author: Campbell Crowley (dev@campbellcrowley.com) const emojiChecker = require('./lib/twemojiChcker.js'); const fs = require('fs'); const rimraf = require('rimraf'); require('./subModule.js').extend(Polling); // Extends the SubModule class. /** * @classdesc Controlls poll and vote commands. * @class * @augments SubModule * @listens Command#poll * @listens Command#vote * @listens Command#endpoll * @listens Command#endvote */ function Polling() { const self = this; /** @inheritdoc */ this.myName = 'Polling'; /** @inheritdoc */ this.helpMessage = null; /** @inheritdoc */ this.initialize = function() { self.command.on(['poll', 'vote'], commandPoll, true); self.command.on(['endpoll', 'endvote'], commandEndPoll, true); self.client.guilds.cache.forEach((g) => { const dir = self.common.guildSaveDir + g.id + guildSubDir; // TODO: Change this to not need to list all subdirectories. Potentially // use a master-list JSON file for each guild instead. fs.readdir(dir, (err, files) => { if (err && err.code != 'ENOENT') { self.error('Failed to read directory: ' + dir); console.error(err); return; } else if (err) { return; } files.forEach((folder) => { self.common.readFile( `${dir}${folder}/${saveFilename}`, (err, data) => { if (err) { self.error( 'Failed to read file: ' + dir + folder + '/' + saveFilename); console.error(err); return; } parsePollString(data); }); }); }); }); }; /** @inheritdoc */ this.shutdown = function() { self.command.deleteEvent('poll'); self.command.deleteEvent('endpoll'); Object.entries(currentPolls).forEach((p) => { if (p[1].timeout) clearTimeout(p[1].timeout); }); }; /** @inheritdoc */ this.save = function(opt) { self.client.guilds.cache.forEach((g) => { const dir = `${self.common.guildSaveDir}${g.id}${guildSubDir}`; if (opt == 'async') { rimraf(dir, (err) => { if (err) { self.error('Failed to clean old polls for guild: ' + g.id); console.error(err); } }); } else { try { rimraf.sync(dir); } catch (err) { self.error('Failed to clean old polls for guild: ' + g.id); console.error(err); } } }); const polls = Object.entries(currentPolls); for (let i = 0; i < polls.length; i++) { const dir = self.common.guildSaveDir + polls[i][1].message.guild.id + guildSubDir + polls[i][1].author + '/'; const filename = dir + saveFilename; const temp = Object.assign({}, polls[i][1]); temp.emojis = temp.emojis.map((el) => el.id || el); temp.message = { channel: temp.message.channel.id, message: temp.message.id, }; delete temp.timeout; const pollString = JSON.stringify(temp); if (opt === 'async') { self.common.mkAndWrite(filename, dir, pollString); } else { self.common.mkAndWriteSync(filename, dir, pollString); } } }; /** * Parse the saved poll data that has been read from file in JSON format. * * @private * @param {string} string The file data. */ function parsePollString(string) { let parsed; try { parsed = JSON.parse(string); } catch (err) { self.error('Failed to parse poll data'); console.error(err); return; } const channel = self.client.channels.resolve(parsed.message.channel); if (!channel) { self.error('Failed to find channel: ' + parsed.message.channel); return; } parsed.emojis = parsed.emojis.map((el) => self.client.emojis.resolve(el) || el); channel.messages.fetch(parsed.message.message) .then((message) => { const poll = (currentPolls[parsed.message.message] = new Poll(parsed.author, message, parsed)); addListenersToPoll(poll, parsed.message.message); }) .catch((err) => { self.error( 'Failed to find message: ' + parsed.message.message + ' in ' + parsed.message.channel); console.error(err); }); } /** * The subdirectory in the guild to store all member polls. * * @private * @constant * @default */ const guildSubDir = '/polls/'; /** * The filename in the member's subdirectory, in the guild's subdirectory, to * save a poll's state. * * @private * @constant * @default */ const saveFilename = 'save.json'; /** * The default reaction emojis to use for a poll. * * @private * @default * @constant */ const defaultEmojis = ['👍', '👎', '🤷']; /** * Stores the currently cached data about all active polls. Organized by * message id that is collecting the poll data. * * @private * @type {object.<Polling~Poll>} */ const currentPolls = {}; /** * @classdesc Stores data related to a single poll. * @class * @private * * @param {string} author ID of the user who started this poll. * @param {Discord~Message} message The message to watch for the results. * @param {Polling~PollOptions} options The settings for this current poll. * @property {string} author ID of the user who started this poll. * @property {Discord~Message} message Reference to the Message object with * the reaction listener. * @property {string} title The user defined text associated with this poll. * @property {?number} endTime The timestamp at which this poll is scheduled * to end. * @property {string[]} emojis The emojis to add as reactions to use as * buttons. * @property {string[]} choices The full string that came with the emoji if * the user specified custom response options. * @property {string[]} timeout The scheduled timeout when this poll will end. */ function Poll(author, message, options) { /** * ID of the user who started this poll. * * @public * @type {string} */ this.author = author; /** * Reference to the Message object with the reaction listener. * * @public * @type {Discord~Message} */ this.message = message; /** * The user defined text associated with this poll. * * @public * @type {string} */ this.title = options.title; /** * The timestamp at which this poll is scheduled to end. * * @public * @type {?number} */ this.endTime = options.endTime; /** * The emojis to add as reactions to use as buttons. * * @public * @type {string[]} */ this.emojis = options.emojis; /** * The full string that came with the emoji if the user specified custom * response options. * * @public * @type {string[]} */ this.choices = options.choices || options.emojis; /** * The scheduled timeout when this poll will end. * * @public * @type {?Timeout} */ this.timeout = null; } /** * Starts a poll. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#poll * @listens Command#vote */ function commandPoll(msg) { if (Object.values(currentPolls).find((obj) => { return obj.author === msg.author.id && obj.message.guild.id === msg.guild.id; })) { self.common.reply( msg, 'Sorry, you may only have one poll per server at a time.\nType ' + msg.prefix + 'endpoll to end your current poll.'); return; } const choicesMatch = msg.text.match(/\[[^\]]*\]/g); let textString = msg.text; if (choicesMatch) { choicesMatch.forEach((el) => { textString = textString.replace(el, ''); }); } textString = textString.trim(); const durationMatch = textString.match(/^(\d+)(\w)(.*)/); let duration = 0; let timeUnit = 'infinite'; let emojis = defaultEmojis; if (durationMatch) { switch (durationMatch[2]) { case 's': duration = durationMatch[1] * 1000; timeUnit = 'second'; break; case 'm': duration = durationMatch[1] * 1000 * 60; timeUnit = 'minute'; break; case 'h': duration = durationMatch[1] * 1000 * 60 * 60; timeUnit = 'hour'; break; case 'd': duration = durationMatch[1] * 1000 * 60 * 60 * 24; timeUnit = 'day'; break; case 'w': duration = durationMatch[1] * 1000 * 60 * 60 * 24 * 7; timeUnit = 'week'; break; } textString = durationMatch[3]; } const embed = new self.Discord.EmbedBuilder(); if (textString) { embed.setTitle(textString); } if (duration) { embed.setDescription( self.common.mention(msg) + '\'s ' + durationMatch[1] + ' ' + timeUnit + ' poll'); } else { embed.setDescription(self.common.mention(msg) + '\'s poll'); } if (choicesMatch) { if (choicesMatch.length > 25) { self.common.reply(msg, 'Sorry, but that is way too many poll options.'); return; } emojis = []; let error = null; choicesMatch.forEach((el, i, obj) => { if (error) return; el = obj[i] = el.replace(/^\[|\]$/g, ''); let matchedEmoji = emojiChecker.match(el); if (!matchedEmoji) { matchedEmoji = el.match(/<a?:\w+:(\d+)>/); if (!matchedEmoji) { error = i + 1; return; } matchedEmoji = self.client.emojis.resolve(matchedEmoji[1]); if (!matchedEmoji) { error = i + 1; return; } matchedEmoji = [matchedEmoji]; } emojis.push(matchedEmoji[0]); embed.addFields([{name: el, value: '\u200B'}]); }); if (error) { self.common.reply( msg, 'Sorry, but choice #' + error + ' doesn\'t have an emoji that I know.'); return; } } const endTime = duration ? Date.now() + duration : null; const options = { title: textString, endTime: endTime, emojis: emojis, choices: choicesMatch, }; msg.channel.send({embeds: [embed]}).then((msg_) => { const poll = new Poll(msg.author.id, msg_, options); currentPolls[msg_.id] = poll; addListenersToPoll(poll, msg_.id); addNextReaction(poll)(); }); } /** * Add timeout and possibly other listeners to a poll. * * @private * * @param {Polling~Poll} poll The poll to register. * @param {string} key The {@link Polling~currentPolls} key to remove the poll * from once the poll has ended. */ function addListenersToPoll(poll, key) { if (poll.endTime) { const duration = poll.endTime - Date.now(); if (duration > 0) { poll.timeout = setTimeout( ((poll, key) => { return () => { endPoll(poll); delete currentPolls[key]; }; })(poll, key), duration); } } } /** * Create a callback for adding all reactions to a message. * * @private * @param {Polling~Poll} poll The poll object for adding reactions. * @param {number} [index=0] The index of the emoji to add first. * @returns {Function} The callback to run on Promise completion. */ function addNextReaction(poll, index = 0) { return function() { if (poll.emojis.length <= index) return; poll.message.react(poll.emojis[index]) .then(addNextReaction(poll, index + 1)); }; } /** * Ends a poll. * * @private * @type {commandHandler} * @param {Discord~Message} msg Message that triggered command. * @listens Command#endpoll * @listens Command#endvote */ function commandEndPoll(msg) { let poll = Object.entries(currentPolls).find((obj) => { return obj[1].author === msg.author.id && obj[1].message.guild.id === msg.guild.id; }); let key; if (poll) { key = poll[0]; poll = poll[1]; } if (!poll || !endPoll(poll)) { self.common.reply(msg, 'You don\'t currently have a poll.'); } if (key) { delete currentPolls[key]; } } /** * End a poll. Does not remove it from {@link Polling~currentPolls}. * * @private * @param {Polling~Poll} poll The poll to end. * @returns {boolean} Was the poll successfully ended. */ function endPoll(poll) { if (!poll || !poll.message || !poll.author) { return false; } if (poll.timeout) { clearTimeout(poll.timeout); poll.timeout = null; } const reactions = poll.message.reactions.cache.filter( (reaction) => poll.emojis.concat(reaction.emoji.name)); const embed = new self.Discord.EmbedBuilder(); if (poll.title) embed.setTitle(poll.title); embed.setDescription(`<@${poll.author}>'s poll results`); if (!poll.endTime) { embed.setFooter({text: 'Poll ended manually'}); } else if (Date.now() < poll.endTime) { embed.setFooter({text: 'Poll ended early'}); } else { embed.setFooter({text: 'Poll ended at time limit'}); } let index = -1; let max = 0; reactions.forEach((r) => { const i = poll.emojis.findIndex((e) => e == r.emoji.name || e == r.emoji.id); if (!poll.choices[i]) return; if (r.count - 1 > max) { index = i; max = r.count - 1; } embed.addFields([{name: poll.choices[i], value: r.count - 1}]); }); if (index > -1) { embed.addFields([{ name: 'Top Choice', value: (poll.choices[index] || poll.emojis[index]) + ' with ' + max + ' votes.', }]); } poll.message.channel.send({embeds: [embed]}).catch((err) => { self.error( 'Failed to send poll results to channel: ' + poll.message.channel.id); console.error(err); }); return true; } } module.exports = new Polling();