Source

classes/team-formation.js

const { GuildEmoji, ReactionEmoji, Role, TextChannel, MessageEmbed, Guild, Collection, User, Message, RoleManager } = require('discord.js');
const { sendEmbedToMember, addRoleToMember, deleteMessage, sendMessageToMember, removeRolToMember } = require('../discord-services');
const BotGuild = require('../db/mongo/BotGuild');
const winston = require('winston');
const Activity = require('./activities/activity');
const BotGuildModel = require('./bot-guild');
const Console = require('./consoles/console');
const { StringPrompt } = require('advanced-discord.js-prompts');

/**
 * @class TeamFormation
 * 
 * The team formation class represents the team formation activity. It helps teams and prospects 
 * find each other by adding their respective information to a catalogue of sorts. Admins have the 
 * ability to customize the messages sent, emojis used, and if they want users to be notified of new
 * posts in the catalogue.
 * 
 */
class TeamFormation extends Activity {
    
    static defaultTeamForm = 'Team Member(s): \nTeam Background: \nObjective: \nFun Fact About Team: \nLooking For: ';
    static defaultProspectForm = 'Name: \nSchool: \nPlace of Origin: \nSkills: \nFun Fact: \nDeveloper or Designer?:';
    static defaultTeamColor = '#60c2e6';
    static defaultProspectColor = '#d470cd';

    /**
     * Creates the team role and returns it.
     * @param {RoleManager} roleManager
     * @returns {Promise<Role>}
     * @static
     * @async
     */
     static async createTeamRole(roleManager) {
        winston.loggers.get(roleManager.guild.id).verbose(`Team formation team role has been created!`, { event: "Team Formation" });
        return await roleManager.create({
            data: {
                name: 'tf-team-leader',
                color: TeamFormation.defaultTeamColor,
            }
        });
    }

    /**
     * Creates the prospect role and returns it.
     * @param {RoleManager} roleManager
     * @returns {Promise<Role>}
     * @static
     * @async
     */
    static async createProspectRole(roleManager) {
        winston.loggers.get(roleManager.guild.id).verbose(`Team formation prospect role has been created!`, { event: "Team Formation" });
        return await roleManager.create({
            data: {
                name: 'tf-prospect',
                color: TeamFormation.defaultProspectColor,
            }
        });
    }

    /**
     * @typedef TeamFormationPartyInfo
     * @property {GuildEmoji | ReactionEmoji} emoji - the emoji used to add this party to the team formation
     * @property {Role} role - the role given to the users of this party
     * @property {String} [form] - the form added to the signup embed for users to respond to. Will not be added if signupEmbed given!
     * @property {MessageEmbed} [signupEmbed] - the embed sent to users when they sign up, must include the form!
     */

    /**
     * @typedef TeamFormationChannels
     * @property {TextChannel} info - the info channel where users read about this activity
     * @property {TextChannel} teamCatalogue - the channel where team info is posted
     * @property {TextChannel} prospectCatalogue - the channel where prospect info is posted
     */

    /**
     * @callback SignupEmbedCreator
     * @param {String} teamEmoji - the emoji used by teams to sign up
     * @param {String} prospectEmoji - the emoji used by prospects to sign up
     * @param {Boolean} isNotificationEnabled - true if parties will be notified when the other party has a new post
     * @return {MessageEmbed}
     */

    /**
     * @typedef TeamFormationInfo
     * @property {TeamFormationPartyInfo} teamInfo
     * @property {TeamFormationPartyInfo} prospectInfo
     * @property {Guild} guild
     * @property {BotGuildModel} botGuild
     * @property {Collection<string, Role>} activityRoles
     * @property {Boolean} [isNotificationsEnabled]
     * @property {SignupEmbedCreator} [signupEmbedCreator]
     */

     /**
      * Create a new team formation.
      * @param {TeamFormationInfo} teamFormationInfo - the team formation information
      */
    constructor(teamFormationInfo) {

        super({
            activityName: 'Team Formation',
            guild: teamFormationInfo.guild,
            roleParticipants: teamFormationInfo.activityRoles,
            botGuild: teamFormationInfo.botGuild
        });

        if (!teamFormationInfo?.teamInfo || !teamFormationInfo?.prospectInfo) throw new Error('Team and prospect info must be given!');
        this.validatePartyInfo(teamFormationInfo.teamInfo);
        this.validatePartyInfo(teamFormationInfo.prospectInfo);
        if (!teamFormationInfo?.guild) throw new Error('A guild is required for a team formation!');

        /**
         * The team information, those teams willing to join will use this.
         * @type {TeamFormationPartyInfo}
         */
        this.teamInfo = {
            emoji : teamFormationInfo.teamInfo.emoji,
            role : teamFormationInfo.teamInfo.role,
            form: teamFormationInfo.teamInfo?.form || TeamFormation.defaultTeamForm,
            signupEmbed : teamFormationInfo.teamInfo?.signupEmbed,
        }

        /**
         * The prospect info, those solo users that want to join a team will use this info.
         * @type {TeamFormationPartyInfo}
         */
        this.prospectInfo = {
            emoji: teamFormationInfo.prospectInfo.emoji,
            role: teamFormationInfo.prospectInfo.role,
            form: teamFormationInfo.prospectInfo?.form || TeamFormation.defaultProspectForm,
            signupEmbed : teamFormationInfo.prospectInfo?.signupEmbed,
        }

        /**
         * The channels that a team formation activity needs.
         * @type {TeamFormationChannels}
         */
        this.channels = {};

        /**
         * True if the parties will be notified when the opposite party has a new post.
         * @type {Boolean}
         */
        this.isNotificationEnabled = teamFormationInfo?.isNotificationsEnabled || false;

        /**
         * A creator of the info embed in case you want it to be different.
         * @type {SignupEmbedCreator}
         */
        this.signupEmbedCreator = teamFormationInfo?.signupEmbedCreator || null;

        winston.loggers.get(this.guild.id).event(`A Team formation has been created!`, { event: "Team Formation"});
        winston.loggers.get(this.guild.id).verbose(`A Team formation has been created!`, { event: "Team Formation", data: {teamFormationInfo: teamFormationInfo}});
    }

    /**
     * Validates a TeamFormationPartyInfo object
     * @param {TeamFormationPartyInfo} partyInfo - the party info to validate
     * @private
     */
    validatePartyInfo(partyInfo) {
        if (!partyInfo?.emoji && typeof partyInfo.emoji != (GuildEmoji || ReactionEmoji)) throw new Error('A Discord emoji is required for a TeamFormationPartyInfo');
        if (!partyInfo?.role && typeof partyInfo.role != Role) throw new Error ('A Discord Role is required in a TeamFormationPartyInfo');
        if (partyInfo.signupEmbed && typeof partyInfo.signupEmbed != MessageEmbed) throw new Error('The message embed must be a Discord Message Embed');
        if (partyInfo.form && typeof partyInfo.form != 'string') throw new Error('The form must be a string!');
    }

    async init() {
        await super.init();
        await this.createChannels();
    }

    /**
     * Will create the TeamFormationChannels object with new channels to use with a new TeamFormation
     * @async
     */
    async createChannels() {

        this.room.channels.category.setName('🏅Team Formation');
        this.room.channels.generalText.delete();
        this.room.channels.generalVoice.delete();

        this.channels.info = await this.room.addRoomChannel({
            name: '👀team-formation',
            permissions: [{ id: this.botGuild.roleIDs.everyoneRole, permissions: { SEND_MESSAGES: false }}],
            isSafe: true,
        });

        this.channels.prospectCatalogue = await this.room.addRoomChannel({
            name: '🙋🏽prospect-catalogue',
            info: {
                topic: 'Information about users looking to join teams can be found here. Happy hunting!!!',
            },
            permissions: [{ id: this.botGuild.roleIDs.everyoneRole, permissions: { SEND_MESSAGES: false }}],
            isSafe: true,
        });

        this.channels.teamCatalogue = await this.room.addRoomChannel({
            name: '💼team-catalogue',
            info: {
                topic: 'Channel for teams to post about themselves and who they are looking for! Expect people to send you private messages.',
            },
            permissions: [{ id: this.botGuild.roleIDs.everyoneRole, permissions: { SEND_MESSAGES: false }}],
            isSafe: true,
        });

        winston.loggers.get(this.guild.id).verbose(`Team formation channels have been created!`, { event: "Team Formation" });
    }

    /**
     * Will start the activity!
     * @param {SignupEmbedCreator} [signupEmbedCreator] - embed creator for the sign in
     * @async
     */
    async start(signupEmbedCreator = null) {
        let embed;

        if (signupEmbedCreator) {
            embed = signupEmbedCreator(this.teamInfo.emoji, this.prospectInfo.emoji, this.isNotificationEnabled);
        } else {
            embed = new MessageEmbed()
            .setColor((await (BotGuild.findById(this.guild.id))).colors.embedColor)
            .setTitle('Team Formation Information')
            .setDescription('Welcome to the team formation section! If you are looking for a team or need a few more members to complete your ultimate group, you are in the right place!')
            .addField('How does this work?', '* Once you react to this message, I will send you a template you need to fill out and send back to me via DM. \n* Then I will post your information in the channels below. \n* Then, other members, teams, or yourself can browse these channels and reach out via DM!')
            .addField('Disclaimer!!', 'By participating in this activity, you consent to other server members sending you a DM.')
            .addField('Teams looking for new members', 'React with ' + this.teamInfo.emoji.toString() + ' and the bot will send you instructions.')
            .addField('Prospects looking for a team', 'React with ' + this.prospectInfo.emoji.toString() + ' and the bot will send you instructions.');
        }

        let signupMsg = await this.channels.info.send(embed);
        signupMsg.react(this.teamInfo.emoji);
        signupMsg.react(this.prospectInfo.emoji);

        winston.loggers.get(this.guild.id).event(`The team formation has started. ${signupEmbedCreator ? 'A custom embed creator was used' : 'The default embed was used.'}`, { event: "Team Formation"});
        winston.loggers.get(this.guild.id).verbose(`The team formation has started. ${signupEmbedCreator ? 'A custom embed creator was used' : 'The default embed was used.'}`, { event: "Team Formation", data: { embed: embed }});

        const signupCollector = signupMsg.createReactionCollector((reaction, user) => !user.bot && (reaction.emoji.name === this.teamInfo.emoji.name || reaction.emoji.name === this.prospectInfo.emoji.name));

        signupCollector.on('collect', (reaction, user) => {
            let isTeam = reaction.emoji.name === this.teamInfo.emoji.name;
            winston.loggers.get(this.guild.id).userStats(`The user ${user.id} is signing up to the team formation as a ${isTeam ? "team" : "prospect"}.`, { event: "Team Formation" })
            this.reachOutToUser(user, isTeam);
        });
    }

    /**
     * Will reach out to the user to ask for the form response to add to the catalogue.
     * @param {User} user - the user joining the team formation activity
     * @param {Boolean} isTeam - true if the user represents a team, else false
     * @async
     */
    async reachOutToUser(user, isTeam) {
        let logger = winston.loggers.get(this.guild.id);

        let console = new Console({
            title: `Team Formation - ${isTeam ? 'Team Format' : 'Prospect Format'}`,
            description: 'We are very excited for you to find your perfect ' + (isTeam ? 'team members.' : 'team.') + '\n* Please **copy and paste** the following format in your next message. ' +
            '\n* Try to respond to all the sections! \n* Once you are ready to submit, react to this message with 🇩 and then send me your information!\n' +
            '* Once you fill your team, please come back and click the ⛔ emoji.',
            channel: await user.createDM(),
            guild: this.guild,
        });

        if (this.isNotificationEnabled) console.addField('READ THIS!', 'As soon as you submit your form, you will be notified of every new ' + (isTeam ? 'available prospect.' : 'available team.') + 
        ' Once you close your form, you will stop receiving notifications!');

        await console.addField('Format:', isTeam ? this.teamInfo.form || TeamFormation.defaultTeamForm : this.prospectInfo.form || TeamFormation.defaultProspectForm);

        await console.addFeature({
            name: 'Send completed form',
            description: 'React to this emoji, wait for my prompt, and send the finished form.',
            emojiName: '🇩',
            callback: async (user, reaction, stopInteracting, console) => {
                // gather and send the form from the user
                try {
                    var catalogueMsg = await this.gatherForm(user, isTeam);
                    logger.verbose(`I was able to get the user's team formation response: ${catalogueMsg.cleanContent}`, { event: "Team Formation" });
                } catch (error) {
                    logger.warning(`While waiting for a user's team formation response I found an error: ${error}`, { event: "Team Formation" });
                    user.dmChannel.send('You have canceled the prompt. You can try again at any time!').then(msg => msg.delete({timeout: 10000}));
                    stopInteracting();
                    return;
                }
        
                // confirm the post has been received
                sendEmbedToMember(user, {
                    title: 'Team Formation',
                    description: 'Thanks for sending me your information, you should see it pop up in the respective channel under the team formation category.' +
                    `Once you find your ${isTeam ? 'members' : 'ideal team'} please react to my original message with ⛔ so I can remove your post. Good luck!!!`,
                }, 15);
                logger.event(`The user ${user.id} has successfully sent their information to the team formation feature.`, { event: "Team Formation" });

                // add role to the user
                addRoleToMember(this.guild.member(user), isTeam ? this.teamInfo.role : this.prospectInfo.role);

                // add remove post feature
                await console.addFeature({
                    name: 'Done with team formation!',
                    description: 'React with this emoji if you are done with team formation.',
                    emojiName: '⛔',
                    callback: (user, reaction, stopInteracting, console) => {
                        // remove message sent to channel
                        deleteMessage(catalogueMsg);
            
                        // confirm deletion
                        sendMessageToMember(user, 'This is great! You are all set! Have fun with your new team! Your message has been deleted.', true);
            
                        removeRolToMember(this.guild.member(user), isTeam ? this.teamInfo.role : this.prospectInfo.role);

                        logger.event(`The user ${user.id} has found a team and has been removed from the team formation feature.`, { event: "Team Formation" });

                        console.delete();
                    }
                });

                console.removeFeature('🇩');

                stopInteracting();
            }
        });

        console.sendConsole();
    }

    /**
     * Will gather the form from a user to add to the catalogues and send it to the correct channel.
     * @param {User} user - the user being prompted
     * @param {Boolean} isTeam - true if the user is a team looking for members
     * @returns {Promise<Message>} - the catalogue message
     * @async
     * @throws Error if user cancels or takes too long to respond to prompt
     */
    async gatherForm(user, isTeam) {
        
        var formMsg = await StringPrompt.single({
            prompt: 'Please send me your completed form, if you do not follow the form your post will be deleted!', 
            channel: user.dmChannel, userId: user.id, time: 30, cancelable: true,
        });

        const embed = new MessageEmbed()
                .setTitle('Information about them can be found below:')
                .setDescription(formMsg.content + '\nDM me to talk -> <@' + user.id + '>')
                .setColor(isTeam ? this.teamInfo.role.hexColor : this.prospectInfo.role.hexColor);

        let channel = isTeam ? this.channels.teamCatalogue : this.channels.prospectCatalogue;

        let catalogueMsg = await channel.send(
            (this.isNotificationEnabled ? '<@&' + (isTeam ? this.prospectInfo.role.id : this.teamInfo.role.id) + '>, ' : '') + 
            '<@' + user.id + (isTeam ? '> and their team are looking for more team members!' : '>  is looking for a team to join!'), {embed: embed});

        winston.loggers.get(channel.guild.id).verbose(`A message with the user's information has been sent to the channel ${channel.name} with id ${channel.id}.`, { event: "Team Formation", data: embed});

        return catalogueMsg;
    }

}
module.exports = TeamFormation;