'use strict';
const Discord = require('discord.js');
// eslint-disable-next-line no-unused-vars
const Typings = require('../typings');
const mongoose = require('mongoose');
// Import chrono-node to schedule weekly lesson reminders
let chrono = require('chrono-node');
chrono = new chrono.Chrono(chrono.en.createConfiguration(false, true));
const { Lesson } = require('../models');
const { dateTimeGroup, enumerateResources, isURL, processResources, titleCase } = require('../modules');
const { commandError, confirmWithReaction, createEmbed, deleteMsg, findGuildConfiguration, sendDirect, sendMsg, successReact, unsubmittedLessons } = require('../handlers');
/**
* Set to ensure that each assigner (identified by their \<User.id>) does not attempt to assign more than one lesson at a time
* @type {Set<Discord.Snowflake>}
* @memberof commands.assign
*/
const recentlyAssigned = new Set();
/**
* @member {commands.Command} commands.assign Assign a lesson to specified instructors by creating a private lesson channel and dispatching a lesson warning
*/
/**
* Complete the \<Command> object from a \<BaseCommand>
* @param {Discord.Guild} guild The \<Guild> that the member shares with the bot
* @returns {Promise<Typings.Command>} The complete \<Command> object with a \<Command.execute()> method
*/
module.exports = async guild => {
const { ids: { lessonsId, trainingIds }, commands: { seen, assign }, colours, emojis } = await findGuildConfiguration(guild);
/**
* @param {Typings.CommandParameters} parameters The \<CommandParameters> to execute this command
*/
assign.execute = async ({ msg }) => {
const { bot } = require('../pronto');
// Extract mentioned members
const lessonInstructors = msg.mentions.members;
try {
// Ensure there was at least one member mentioned
if (lessonInstructors.size === 0) throw 'You must tag a user.';
// Ensure no mentioned members are a bot
else if (lessonInstructors.some(mention => mention.user.bot)) throw 'You cannot assign a lesson to a bot!';
// Ensure the assigner does not already exist in the recentlyAssigned set
else if (recentlyAssigned.has(msg.author.id)) throw 'You are already assigning a lesson!';
}
catch (thrownError) { return commandError(msg, thrownError, assign.error); }
// Delete the command message
deleteMsg(msg);
// Add the assigner's Id to the recentlyAssigned set
// This ensures that each assigner cannot attempt to assign more than one lesson at a time
recentlyAssigned.add(msg.author.id);
// Create assign embed
const assignEmbed = new Discord.MessageEmbed()
.setTitle('Assigning Lesson...')
.setAuthor(msg.member.displayName, msg.author.displayAvatarURL({ dynamic: true }))
.setColor(colours.success)
.setDescription('Type `restart` to start again, or `cancel` to abort.')
// Resolve the mentioned members into a formatted string and add new field
.addField('Instructor(s)', processMentions(lessonInstructors))
.setFooter(await dateTimeGroup(guild));
// Send the assign embed
sendDirect(msg.author, { embeds: [assignEmbed] }, msg.channel);
// Object defining the required inputs to properly assign a lesson
// Object keys represent the type of data retained - the object is passed into the getUserInput() function, which returns an object with matching keys storing the user's input
const neededInputs = {
lessonName: {
prompt: 'What is the name of the lesson?',
type: 'TEXT',
allowMultiple: false,
},
dueTimestamp: {
prompt: 'When is the lesson plan due?',
type: 'DATE',
allowMultiple: false,
},
lessonTimestamp: {
prompt: 'When will the lesson be taught?',
type: 'DATE',
allowMultiple: false,
},
resources: {
prompt: 'Provide any resources for the lesson if applicable.\n\nReply `done` when finished.',
type: 'ATTACHMENT',
allowMultiple: true,
},
};
// Pass the neededInputs object into the getUserInput() function to collect user's input
const userInput = await getUserInput(msg, neededInputs, colours);
// If getUserInput() returns the symbol 'CANCEL', cancel the assigning of the lesson
if (userInput === 'CANCEL') {
// Create cancellation embed
const cancelEmbed = new Discord.MessageEmbed()
.setAuthor(bot.user.tag, bot.user.avatarURL({ dynamic: true }))
.setColor(colours.error)
.setDescription('**Cancelled.**')
.setFooter(await dateTimeGroup(guild));
// Remove the assigner's Id from the recentlyAssigned set
recentlyAssigned.delete(msg.author.id);
// Send the cancellation embed and cease further execution
return sendDirect(msg.author, { embeds: [cancelEmbed] }, null, true);
}
// Destructure desired user's input from the returned value of the getUserInput() function
const { lessonName, dueTimestamp, lessonTimestamp, resources } = userInput;
// Obtain and store formatted date-time groups from parsed time stamps
const dueDate = await dateTimeGroup(guild, dueTimestamp);
const lessonDate = await dateTimeGroup(guild, lessonTimestamp);
// Create lesson assignment confirmation embed
const lessonEmbed = new Discord.MessageEmbed()
.setTitle(`Lesson Assignment - ${lessonName}`)
.setAuthor(msg.member.displayName, msg.author.displayAvatarURL({ dynamic: true }))
.setColor(colours.warn)
// Call processMentions() to format the lesson instructors
.addField('Instructor(s)', processMentions(lessonInstructors))
.addField('Lesson', lessonName)
.addField('Lesson Plan Due', dueDate)
.addField('Lesson Date', lessonDate)
// Call modules.enumerateResources() to format and output the lesson resources
.addField('Resources', enumerateResources(resources, true))
.setFooter('Use the reactions below to confirm or cancel.');
// Send the lesson assignment confirmation embed to the assigner
sendDirect(msg.author, { embeds: [lessonEmbed] }, null, true)
.then(dm => {
/**
* Create a private lesson channel for specified instructors and dispatch the lesson warning, as well as a message to request acknowledgement from the instructor(s)
* @function commands.assign~assignLesson
*/
const assignLesson = async () => {
/**
* Set channel topic to be a list of the instructor(s)'s mentions, and create the channel under the lessons category channel
* @type {Discord.GuildChannelCreateOptions}
*/
const channelOptions = { topic: processMentions(lessonInstructors), parent: lessonsId };
// Create the private lesson channel, with a name matching the lesson name and with the above channel options
// '.' in lesson name are invalid characters for channel names, therefore substitute them with '-'
await guild.channels.create(lessonName.replace('.', '-'), channelOptions)
.then(async channel => {
// Once the channel has been created, ensure the correct visibility permissions are applied
// Make the channel private for all users other than the bot, training cell, and instructors
await channel.createOverwrite(bot.user.id, { 'VIEW_CHANNEL': true });
channel.createOverwrite(guild.roles.everyone, { 'VIEW_CHANNEL': false });
trainingIds.forEach(staff => channel.createOverwrite(staff, { 'VIEW_CHANNEL': true }));
lessonInstructors.each(instructor => channel.createOverwrite(instructor, { 'VIEW_CHANNEL': true }));
// Create and save a new database entry for the lesson
saveLesson(channel.id);
// Remove the assigner's Id from the recentlyAssigned set now that lesson assignment has been completed
recentlyAssigned.delete(msg.author.id);
// Modify the lesson assignment confirmation embed to repurpose it into the lesson warning embed
lessonEmbed.setTitle(`Lesson Warning - ${lessonName}`);
lessonEmbed.setDescription('You have been assigned a lesson, use this channel to organise yourself.');
lessonEmbed.setFooter(await dateTimeGroup(guild));
// If there is only one instructor, set the author of the lesson warning embed to be the instructor's name and display avatar
if (lessonInstructors.size === 1) lessonEmbed.setAuthor(lessonInstructors.first().displayName, lessonInstructors.first().user.displayAvatarURL({ dynamic: true }));
// Otherwise, if there are multiple instructors, set the author to be the guild's name and guild icon
else lessonEmbed.setAuthor(guild.name, guild.iconURL({ dynamic: true }));
// Send the lesson warning embed to the private lesson channel
await sendMsg(channel, { embeds: [lessonEmbed] });
// Call handlers.unsubmittedLessons() to update the master unsubmitted lessons tracker embed
unsubmittedLessons(guild);
// Retrieve the guild's success emoji
const successEmoji = msg.guild.emojis.cache.find(emoji => emoji.name === emojis.success.name);
// Create a new embed to prompt the instructor(s)'s acknowledgement of the lesson warning
const ackEmbed = new Discord.MessageEmbed()
.setDescription(`Click the ${successEmoji} to acknowledge receipt of this lesson warning.\n\nAlternatively, you can manually type \`!seen\`.`)
.setColor(colours.primary);
// Send the acknowledgement embed, and tag each instructor in the message body
sendMsg(channel, { content: processMentions(lessonInstructors), embeds: [ackEmbed] })
.then(async ackMessage => {
// Add the success reaction to the acknowledgement message
await successReact(ackMessage);
// Filter reaction collector for reactions only of the success emoji, and that are made by an instructor
const filter = (reaction, user) => reaction.emoji.name === emojis.success.name && lessonInstructors.has(user.id);
// Create a new reaction collector on the acknowledgement message with the filter applied
const collector = ackMessage.createReactionCollector(filter, { dispose: true });
// Execute the commands\seen.js <Command> when an instructor acknowledges the lesson warning via reaction
collector.on('collect', async (_, user) => bot.commands.get(seen.command).execute({ msg: ackMessage, user: user }));
});
})
.catch(error => console.error(`Error creating ${lessonName} in ${guild.name}\n`, error));
};
/**
* Remove the assigner's Id from the recentlyAssigned set
* @function commands.assign~assignCancelled
*/
const assignCancelled = () => recentlyAssigned.delete(msg.author.id);
// Call handlers.confirmWithReaction() on the lesson assignment confirmation embed with assignLesson() and assignCancelled() as callbacks
return confirmWithReaction(msg, dm, assignLesson, assignCancelled);
});
/**
* Create a new mongoose \<Lesson> document for the assigned lesson
* @function commands.assign~saveLesson
* @param {Discord.Snowflake} channelId The \<TextChannel.id> of the private lesson channel created for the lesson
* @returns {Promise<Typings.Lesson>} The mongoose document for the lesson
*/
async function saveLesson(channelId) {
// For each instructor, create a new nested object within the instructors object with an Id property and a boolean flag to record acknowledgement status
const instructors = Object.fromEntries(
lessonInstructors.map(mention => [mention.id, {
id: mention.id,
seen: false,
}]),
);
/**
* Create a new \<Lesson> document
* @type {Typings.Lesson}
*/
const lesson = await new Lesson({
_id: new mongoose.Types.ObjectId(),
lessonId: channelId,
lessonName,
instructors,
dueDate,
dueTimestamp,
lessonDate,
lessonTimestamp,
assignedResources: enumerateResources(resources),
});
// Save the <Lesson> document and return it
return await lesson.save().catch(error => console.error(error));
}
};
return assign;
};
/**
* @typedef {'TEXT' | 'DATE' | 'ATTCHMENT'} commands.assign.InputType A \<string> representation of the type of input
* - Text inputs only require an input, with no additional error checking
* - Date inputs are parsed through chrono to ensure a valid date is recognised and return a Unix timestamp (ms)
* - Attachments allow attachments to be uploaded or URLs to be entered, with appropriate error checking
*/
/**
* Collect the all the required inputs from the user and return an object with their completed inputs
* @function commands.assign~getUserInput
* @param {Discord.Message} msg The \<Message> that executed the \<Command>
* @param {Object.<string, {prompt: string, type: commands.assign.InputType, allowMultiple: boolean}>} prompts An object defining the individual inputs to prompt for
* - Must contain an object.prompt property of type \<string>
* - Must contain an object.type property for the type of input of type \<string>: `TEXT` | `DATE` | `ATTACHMENT`
* - Must contain an object.allowMultiple \<boolean>: `true` | `false`
* - Text inputs only require an input, with no additional error checking
* - Date inputs are parsed through chrono to ensure a valid date is recognised and return a Unix timestamp (ms)
* - Attachments allow attachments to be uploaded or URLs to be entered, with appropriate error checking
* @param {Typings.Colours} colours The guild's colour object
* @returns {Promise<Object.<string, string | number | string[] | number[]> | 'CANCEL'>} An object with the user's completed inputs stored in each respective property, or the symbol `CANCEL` to represent a cancelled lesson assignment
*/
async function getUserInput(msg, prompts, colours) {
// Initialise an empty object in preparation to store the user's input to each property of prompts as they are completed individually in turn
const input = {};
// Loop through each needed input prompt
for (const [key, value] of Object.entries(prompts)) {
// If the current prompt allows for multiple inputs, call the whileLoop() function
if (value.allowMultiple) input[key] = await whileLoop(createEmbed(value.prompt, colours.primary), msg, value.type, colours, value.allowMultiple);
// Otherwise, call the msgPrompt() function
else input[key] = await msgPrompt(createEmbed(value.prompt, colours.primary), msg, value.type, colours, value.allowMultiple);
try {
// If the received symbol is to 'RESTART', restart input by returning getUserInput() recursively
if (input[key] === 'RESTART') return await getUserInput(msg, prompts, colours);
// Otherwise, if the received symbol is to 'CANCEL', return the same symbol from getUserInput()
else if (input[key] === 'CANCEL') return 'CANCEL';
}
catch { null; }
}
// If all inputs have been successfully completed, return the completed input object
return input;
}
/**
* Display a prompt to the user and collect & process their input according to the type of input
* @function commands.assign~msgPrompt
* @param {Discord.MessageEmbed} prompt The embed to use to prompt the user for the input
* @param {Discord.Message} msg The \<Message> that executed the \<Command>
* @param {commands.assign.InputType} type The type of input being prompted for: `TEXT` | `DATE` | `ATTACHMENT`
* @param {Typings.Colours} colours The guild's colour object
* @param {boolean} allowMultiple Whether to allow multiple inputs
* @returns {Promise<string | number | 'RESTART' | 'CANCEL' | 'DONE'>} The user's input, or the symbols `RESTART` | `CANCEL` | `DONE`
* - Text inputs return a \<string>
* - Date inputs return a Unix timestamp (ms) as \<number>
* - Attachments return a URL formatted as a hyperlink using [`modules.processResources()`]{@link modules.processResources} as \<string>
* - `RESTART` = restart input from the beginning
* - `CANCEL` = cancel lesson assignment
* - `DONE` = attachment input is complete
*/
async function msgPrompt(prompt, msg, type, colours, allowMultiple) {
// Filter message collector for messages only sent by command author
const filter = message => message.author.id === msg.author.id;
// Send the prompt embed to the user, and await the user's input
// Use Promise.all() to wait for the user's input before proceeding
const input = await Promise.all([
sendDirect(msg.author, { embeds: [prompt] }, null, true),
msg.author.dmChannel.awaitMessages(filter, { max: 1 }),
])
// Extract the user's input from the resolved Promises
.then(resolved => resolved[1].first());
// Use a try {} catch {} block to utilise throw
try {
// If the user desires to restart or cancel input, return the appropriate symbol
if (input.content.toLowerCase() === 'restart') throw 'RESTART';
else if (input.content.toLowerCase() === 'cancel') throw 'CANCEL';
// Otherwise, if the user has completed their multiple inputs, and the current prompt allows multiple inputs (such that 'done' is a keyword), return the 'DONE' symbol
else if (input.content.toLowerCase() === 'done' && allowMultiple) throw 'DONE';
if (type === 'TEXT' || type === 'DATE') {
// If the type of input to be validated is 'TEXT' or 'DATE', ensure the input is not empty
// Uploaded attachments have a null <Message.content>, so cannot be checked here
if (!input.content) {
// If the input is empty, send an error and try again
sendDirect(msg.author, { embeds: [createEmbed('You must enter something!', colours.error)] }, null, true);
throw await msgPrompt(prompt, msg, type, colours, allowMultiple);
}
// If the input is of type 'DATE', try to parse the date and obtain a Unix timestamp
if (type === 'DATE') {
const parsedDate = chrono.parseDate(input.content);
// If no date has been successfully parsed, send an error and try again
if (!parsedDate) {
sendDirect(msg.author, { embeds: [createEmbed('I don\'t recognise that date, please try again.', colours.error)] }, null, true);
throw await msgPrompt(prompt, msg, type, colours, allowMultiple);
}
// If a date has been successfully parsed, return its Unix (ms) timestamp
throw parsedDate.setHours(18, 0, 0, 0).valueOf();
}
// If the input is of type 'TEXT' and is not empty, return it through modules.titleCase()
throw titleCase(input.content);
}
else if (type === 'ATTACHMENT') {
// Otherwise, if the input is of type 'ATTACHMENT', parse the input for URLs and check for uploaded attachments
const substrings = input.content.split(/ +/);
const attachments = input.attachments.first();
// Filter the substrings of the input message for URLs
const urls = substrings.filter(substr => isURL(substr));
// If there have not been any attachments uploaded and no URLs have been successfully parsed, send an error and try again
if (!attachments && !urls.length) {
sendDirect(msg.author, { embeds: [createEmbed('You must attach a file or enter a URL!', colours.error)] }, null, true);
throw await msgPrompt(prompt, msg, type, colours, allowMultiple);
}
// Otherwise, if there have successfully been attachments and/or URLs input, return it through modules.processResources()
throw processResources(attachments, urls);
}
}
catch (thrown) { return thrown; }
}
/**
* Implements a loop to allow multiple inputs for a given prompt, which are returned in an array
* @function commands.assign~whileLoop
* @param {Discord.MessageEmbed} prompt The embed to use to prompt the user for the input
* @param {Discord.Message} msg The \<Message> that executed the \<Command>
* @param {commands.assign.InputType} type The type of input being prompted for: `TEXT` | `DATE` | `ATTACHMENT`
* @param {Typings.Colours} colours The guild's colour object
* @param {boolean} allowMultiple Whether to allow multiple inputs
* @returns {Promise<string[] | number[] | 'RESTART' | 'CANCEL'>} An array of the user's inputs, or the symbols `RESTART` | `CANCEL`
* - The type stored within the array is dependent on the input type returned by msgPrompt()
* - `RESTART` = restart input from the beginning
* - `CANCEL` = cancel lesson assignment
*/
async function whileLoop(prompt, msg, type, colours, allowMultiple) {
// Initialise an empty array to store the user's multiple inputs
const array = [];
/**
* A recursive function which repeatedly prompts the user for input until loop is ended
* @returns {Promise<string[] | number[] | 'RESTART' | 'CANCEL'>} An array of the user's inputs, or the symbols `RESTART` | `CANCEL`
* - The type stored within the array is dependent on the input type returned by msgPrompt()
* - `RESTART` = restart input from the beginning
* - `CANCEL` = cancel lesson assignment
*/
async function loop() {
// Call the msgPrompt() function and collect & process the user's input
const input = await msgPrompt(prompt, msg, type, colours, allowMultiple);
// If the user desires to restart or cancel input, return the appropriate symbol
if (input === 'RESTART') return 'RESTART';
else if (input === 'CANCEL') return 'CANCEL';
// Otherwise, if the user has completed input, return the array
else if (input === 'DONE') return array;
// Otherwise, if the user has yet to complete input, push the new input to the array and loop again
else {
array.push(input);
return await loop(prompt, msg, type);
}
}
return await loop();
}
/**
* Process a Collection\<GuildMember.Snowflake, GuildMember> into a formatted string of user mentions
* @function commands.assign~processMentions
* @param {Discord.Collection<Discord.Snowflake, Discord.GuildMember>} members A Collection\<GuildMember.Snowflake, GuildMember> to process
* @returns {string} A newline-delimited string of formatted user mentions
*/
function processMentions(members) {
// Map the <GuildMember> instances to a new string[] of mentions, then join the string[] with a newline separator
return members.map(member => member.toString())
.join('\n');
}
source