// 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) self.client.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.MessageEmbed();
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.addField(el, '\u200B', true);
});
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(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 = self.client.setTimeout(
(function(poll, key) {
return function() {
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) {
self.client.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.MessageEmbed();
if (poll.title) embed.setTitle(poll.title);
embed.setDescription(`<@${poll.author}>'s poll results`);
if (!poll.endTime) {
embed.setFooter('Poll ended manually');
} else if (Date.now() < poll.endTime) {
embed.setFooter('Poll ended early');
} else {
embed.setFooter('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.addField(poll.choices[i], r.count - 1, true);
});
if (index > -1) {
embed.addField(
'Top Choice', (poll.choices[index] || poll.emojis[index]) +
' with ' + max + ' votes.');
}
poll.message.channel.send(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();