Source

classes/tickets/ticket.js

const { Collection, User, Role } = require('discord.js');
const winston = require('winston');
const discordServices = require('../../discord-services');
const Console = require('../consoles/console');
const Feature = require('../consoles/feature');
const Room = require('../room');
const TicketManager = require('./ticket-manager');

class Ticket {

    /**
     * @typedef TicketConsoles
     * @property {Console} groupLeader
     * @property {Console} ticketManager - Message sent to incoming ticket channel for helpers to see.
     * @property {Console} ticketRoom - The message with the information embed sent to the ticket channel once the ticket is open.
     */

    /**
     * @typedef TicketGarbageInfo
     * @property {Number} noHelperInterval - Interval ID for when there are no more helpers in the ticket
     * @property {Boolean} mentorDeletionSequence - Flag to check if a deletion sequence has already been triggered by all mentors leaving the ticket; if so, there will not be
     * another sequence started for inactivity
     * @property {Boolean} exclude - Flag for whether this ticket is excluded from automatic garbage collection
     */

    /**
     * @param {Collection<String, User>} hackers 
     * @param {String} question 
     * @param {Role} requesterRole
     * @param {Number} ticketNumber
     * @param {TicketManager} ticketManager 
     */
    constructor(hackers, question, requestedRole, ticketNumber, ticketManager) {

        /**
         * Ticket number
         * @type {Number}
         */
        this.id = ticketNumber;

        /**
         * The room this ticket will be solved in.
         * @type {Room}
         */
        this.room = ticketManager.systemWideTicketInfo.isAdvancedMode ? new Room(ticketManager.parent.guild, ticketManager.parent.botGuild, `Ticket-${ticketNumber}`, undefined, hackers.clone()) : null;

        /**
         * Question from hacker
         * @type {String}
         */
        this.question = question;

        /**
         * @type {Role}
         */
        this.requestedRole = requestedRole;

        /**
         * All the group members, group leader should be the first one!
         * @type {Collection<String, User>} - <ID, User>
         * Must clone the Map since we edit it.
         */
        this.group = hackers.clone();

        /**
         * Mentors who join the ticket
         * @type {Collection<String, User>} - <ID, User>
         */
        this.helpers = new Collection();

        /**
         * All the consoles sent out.
         * GroupLeader -> sent via DM to leader, they can cancel the ticket from there
         * ticketManager -> sent to the helper channel
         * ticketRoom -> sent to the ticket room once created for users to leave
         * @type {TicketConsoles}
         */
        this.consoles = {
            groupLeader: null,
            ticketManager: null,
            ticketRoom: null,
        };

        /**
         * Garbage collector info.
         * @type {TicketGarbageInfo}
         */
        this.garbageCollectorInfo = {
            noHelperInterval: null,
            mentorDeletionSequence: false,
            exclude: false,
        };

        /**
         * The status of this ticket
         * @type {Ticket.STATUS}
         */
        this.status = null;

        /**
         * @type {TicketManager}
         */
        this.ticketManager = ticketManager; 
    }

    /**
     * This function is called by the ticket's Cave class to change its status between include/exclude for automatic garbage collection.
     * If a previously excluded ticket is re-included, the bot starts listening for inactivity as well.
     * @param {Boolean} exclude - true if ticket is now excluded from garbage collection, false if not
     */
    async includeExclude(exclude) {
        // oldExclude saves the previous inclusion status of the ticket
        var oldExclude = this.garbageCollectorInfo.exclude;
        // set excluded variable to new status
        this.garbageCollectorInfo.exclude = exclude;

        // if this ticket was previously excluded and is now included, start the listener for inactivity
        if (oldExclude && !exclude) {
            this.startChannelActivityListener();
        }
    }

    /**
     * Change the status of this ticket.
     * @param {String} status - one of Ticket.STATUS
     * @param {String} [reason] - the reason for the change
     * @param {User} [user] - user involved with the status change
     * @async
     */
    async setStatus(status, reason = '', user) {
        this.status = status;
        
        switch(status) {
            case Ticket.STATUS.new:
                // let user know that ticket was submitted and give option to remove ticket
                await this.contactGroupLeader();

                this.newStatusCallback();
                break;

            case Ticket.STATUS.taken:
                if (this.ticketManager.systemWideTicketInfo.isAdvancedMode) await this.advancedTakenStatusCallback(user);
                else await this.basicTakenStatusCallback(user);
                break;
            case Ticket.STATUS.closed:
                this.delete(reason);
                break;
        }
    }

    /**
     * The new ticket status callback creates the ticket manager helper console and sends it to the incoming tickets channel.
     * @private
     */
    async newStatusCallback() {
        const ticketManagerMsgEmbed = this.ticketManager.ticketDispatcherInfo.embedCreator(this);

        this.consoles.ticketManager = new Console({
            title: ticketManagerMsgEmbed.title,
            description: ticketManagerMsgEmbed.description,
            channel: this.ticketManager.ticketDispatcherInfo.channel,
            guild: this.ticketManager.parent.guild,
            color: '#fff536'
        });

        ticketManagerMsgEmbed.fields.forEach((embedField => {
            this.consoles.ticketManager.addField(embedField.name, embedField.value, embedField.inline);
        }));

        let joinTicketFeature = Feature.create({
            name: 'Can you help them?',
            description: 'If so, react to this message with the emoji!',
            emoji: this.ticketManager.ticketDispatcherInfo.takeTicketEmoji,
            callback: (user, reaction, stopInteracting) => {
                if (this.status === Ticket.STATUS.new) {
                    this.setStatus(Ticket.STATUS.taken, 'helper has taken the ticket', user);
                }
                stopInteracting();
            }
        });

        this.consoles.ticketManager.addFeature(joinTicketFeature);

        this.consoles.ticketManager.sendConsole(`<@&${this.requestedRole.id}>`);
    }

    /**
     * Contacts the group leader and sends a console with the ability to remove the ticket.
     * @private
     */
    async contactGroupLeader() {
        let removeTicketEmoji = '⚔️';
        this.consoles.groupLeader = new Console({
            title: 'Ticket was Successful!',
            description: `Your ticket to the ${this.ticketManager.parent.name} group was successful! It is ticket number ${this.id}`,
            channel: await this.group.first().createDM(),
            guild: this.ticketManager.parent.guild,
            features: new Collection([
                [removeTicketEmoji, {
                    name: 'Remove the ticket',
                    description: 'React to this message if you don\'t need help any more!',
                    emojiName: removeTicketEmoji,
                    callback: (user, reaction, stopInteracting) => {
                        // make sure user can only close the ticket if no one has taken the ticket
                        if (this.status === Ticket.STATUS.new) this.setStatus(Ticket.STATUS.closed, 'group leader closed the ticket');
                    },
                }]
            ]),
            options: { max: 1 }
        });

        this.consoles.groupLeader.sendConsole();
    }

    /**
     * Callback for status change to taken when ticket manager is NOT in advanced mode.
     * @param {User} helper - the user who is taking the ticket
     */
    async basicTakenStatusCallback(helper) {
        this.addHelper(helper);

        // edit ticket manager helper console with mentor information
        await this.consoles.ticketManager.addField('This ticket is being handled!', `<@${helper.id}> is helping this team!`);
        await this.consoles.ticketManager.changeColor('#36c3ff');

        // update dm with user to reflect that their ticket has been accepted
        this.consoles.groupLeader.addField('Your ticket has been taken by a helper!', 'Expect a DM from a helper soon!');
        this.consoles.groupLeader.stopConsole();
    }

    /**
     * Callback for status change for when the ticket is taken by a helper.
     * @param {User} helper - the helper user
     * @private
     */
    async advancedTakenStatusCallback(helper) {
        await this.room.init();

        // add helper and clear the ticket reminder timeout
        this.addHelper(helper);

        // edit ticket manager helper console with mentor information
        await this.consoles.ticketManager.addField('This ticket is being handled!', `<@${helper.id}> is helping this team!`);
        await this.consoles.ticketManager.changeColor('#36c3ff');

        let takeTicketFeature = Feature.create({
            name: 'Still want to help?',
            description: `Click the ${this.ticketManager.ticketDispatcherInfo.joinTicketEmoji.toString()} emoji to join the ticket!`,
            emoji: this.ticketManager.ticketDispatcherInfo.joinTicketEmoji,
            callback: (user, reaction, stopInteracting) => {
                if (this.status === Ticket.STATUS.taken) this.helperJoinsTicket(user);
                stopInteracting();
            }
        });
        await this.consoles.ticketManager.addFeature(takeTicketFeature);

        // update dm with user to reflect that their ticket has been accepted
        this.consoles.groupLeader.addField('Your ticket has been taken by a helper!', 'Please go to the corresponding channel and read the instructions there.');
        this.consoles.groupLeader.stopConsole();

        // send message mentioning all the parties involved so they get a notification
        let notificationMessage = '<@' + helper.id + '> ' + this.group.array().join(' ');
        this.room.channels.generalText.send(notificationMessage).then(msg => msg.delete({ timeout: 15000 }));

        let leaveTicketEmoji = '👋🏽';

        this.consoles.ticketRoom = new Console({
            title: 'Original Question',
            description: `<@${this.group.first().id}> has the question: ${this.question}`,
            channel: this.room.channels.generalText,
            color: this.ticketManager.parent.botGuild.colors.embedColor,
            guild: this.ticketManager.parent.guild,
        });

        this.consoles.ticketRoom.addField('Thank you for helping this team.', `<@${helper.id}> best of luck!`);
        this.consoles.ticketRoom.addFeature({
            name: 'When done:',
            description: `React to this message with ${leaveTicketEmoji} to lose access to these channels!`,
            emojiName: leaveTicketEmoji,
            callback: (user, reaction, stopInteracting) => {
                // delete the mentor or the group member that is leaving the ticket
                this.helpers.delete(user.id);
                this.group.delete(user.id);

                this.room.removeUserAccess(user);

                // if all hackers are gone, delete ticket channels
                if (this.group.size === 0) {
                    this.setStatus(Ticket.STATUS.closed, 'no users on the ticket remaining');
                }

                // tell hackers all mentors are gone and ask to delete the ticket if this has not been done already 
                else if (this.helpers.size === 0 && !this.garbageCollectorInfo.mentorDeletionSequence && !this.garbageCollectorInfo.exclude) {
                    this.garbageCollectorInfo.mentorDeletionSequence = true;
                    this.askToDelete('mentor');
                }

                stopInteracting();
            }
        });

        this.consoles.ticketRoom.sendConsole();

        //create a listener for inactivity in the text channel
        this.startChannelActivityListener();
    }

    /**
     * Callback for collector for when a new helper joins the ticket.
     * @param {User} helper - the new helper user
     * @private
     */
    helperJoinsTicket(helper) {
        this.addHelper(helper, this.garbageCollectorInfo.noHelperInterval);

        discordServices.sendMsgToChannel(this.room.channels.generalText, helper.id, 'Has joined the ticket!', 10);

        // update the ticket manager and ticket room embeds with the new mentor
        this.consoles.ticketManager.addField('More hands on deck!', '<@' + helper.id + '> Joined the ticket!');
        this.consoles.ticketRoom.addField('More hands on deck!', '<@' + helper.id + '> Joined the ticket!');
    }

    /**
     * Adds a helper to the ticket.
     * @param {User} user - the user to add to the ticket as a helper
     * @param {NodeJS.Timeout} [timeoutId] - the timeout to clear due to this addition
     * @private
     */
    addHelper(user, timeoutId) {
        this.helpers.set(user.id, user);
        if (this.room) this.room.giveUserAccess(user);
        if (timeoutId) clearTimeout(timeoutId);
    }

    /**
     * Main deletion sequence: mentions and asks hackers if ticket can be deleted, and deletes if there is no response or indicates that
     * it will check in again later if someone does respond
     * @param {String} reason - 'mentor' if this deletion sequence was initiated by the last mentor leaving, 'inactivity' if initiated by
     * inactivity in the text channel
     * @private
     */
    async askToDelete(reason) {
        // assemble message to send to hackers to verify if they still need the ticket
        let msgText = `${this.group.array().map(user => '<@' + user.id + '>').join(' ')} `;
        if (reason === 'inactivity') {
            msgText += `${this.helpers.array().map(user => '<@' + user.id + '>').join(' ')} Hello! I detected some inactivity on this channel and wanted to check in.\n`;
        } else if (reason === 'mentor') {
            msgText += 'Hello! Your mentor(s) has/have left the ticket.\n';
        }

        let warning = await this.room.channels.generalText.send(`${msgText} If the ticket has been solved, please click the 👋 emoji above 
            to leave the channel. If you need to keep the channel, please click the emoji below, 
            **otherwise this ticket will be deleted in ${this.ticketManager.systemWideTicketInfo.garbageCollectorInfo.bufferTime} minutes**.`);

        await warning.react('🔄');

        // reaction collector to listen for someone to react with the emoji for more time
        const deletionCollector = warning.createReactionCollector((reaction, user) => !user.bot && reaction.emoji.name === '🔄', { time: this.ticketManager.systemWideTicketInfo.garbageCollectorInfo.bufferTime * 60 * 1000, max: 1 });
        
        deletionCollector.on('end', async (collected) => {
            // if a channel has already been deleted by another process, stop this deletion sequence
            if (collected.size === 0 && !this.garbageCollectorInfo.exclude && this.status != Ticket.STATUS.closed) { // checks to see if no one has responded and this ticket is not exempt
                this.setStatus(Ticket.STATUS.closed, 'inactivity');
            } else if (collected.size > 0) {
                await this.room.channels.generalText.send('You have indicated that you need more time. I\'ll check in with you later!');

                // set an interval to ask again later
                this.garbageCollectorInfo.noHelperInterval = setInterval(() => this.askToDelete(reason), this.ticketManager.systemWideTicketInfo.garbageCollectorInfo.inactivePeriod * 60 * 1000);
            }
        });
    }

    /**
     * Uses a message collector to see if there is any activity in the room's text channel. When the collector ends, if it collected 
     * no messages and there is no one on the voice channels then ask to delete and listen once again.
     * @async
     * @private
     */
    async startChannelActivityListener() {
        // message collector that stops when there are no messages for inactivePeriod minutes
        const activityListener = this.room.channels.generalText.createMessageCollector(m => !m.author.bot, { idle: this.ticketManager.systemWideTicketInfo.garbageCollectorInfo.inactivePeriod * 60 * 1000 });
        activityListener.on('end', async collected => {
            if (collected.size === 0 && this.room.channels.generalVoice.members.size === 0 && this.status === Ticket.STATUS.taken) {
                await this.askToDelete('inactivity');
                
                // start listening again for inactivity in case they ask for more time
                this.startChannelActivityListener(); 
            }
        });
    }

    /**
     * Deletes the ticket, the room and the intervals.
     * @param {String} reason - the reason to delete the ticket
     * @private
     */
    delete(reason) {
        // update ticketManager msg and let user know the ticket is closed
        this.consoles.ticketManager.addField(
            'Ticket Closed', 
            `This ticket has been closed${reason ? ' due to ' + reason : '!! Good job!'}`
        );
        this.consoles.ticketManager.changeColor('#43e65e');
        this.consoles.ticketManager.stopConsole();
        
        this.consoles.groupLeader.addField(
            'Ticket Closed!', 
            `Your ticket was closed due to ${reason}. If you need more help, please request another ticket!`
        );
        this.consoles.groupLeader.stopConsole();

        // delete the room, clear intervals
        if (this.room) this.room.delete();
        clearInterval(this.garbageCollectorInfo.noHelperInterval);
        
        if (this.consoles?.ticketRoom) this.consoles.ticketRoom.stopConsole();
    }
}

/**
 * The possible status of the ticket.
 * @enum {String}
 * @static
 */
Ticket.STATUS = {
    /** Ticket is open for someone to take. */
    new: 'new',
    /** Ticket has been dealt with and is closed. */
    closed: 'closed',
    /** Ticket is being handled by someone. */
    taken: 'taken',
};

module.exports = Ticket;