Source

classes/tickets/ticket-manager.js

const { Collection, GuildEmoji, ReactionEmoji, TextChannel, Guild, Role, User } = require('discord.js');
const Ticket = require('./ticket');
const BotGuildModel = require('../bot-guild');
const Console = require('../consoles/console');
const Feature = require('../consoles/feature');
const { sendMsgToChannel } = require('../../discord-services');
const winston = require('winston');
const { StringPrompt } = require('advanced-discord.js-prompts');
const Activity = require('../activities/activity');


/**
 * Represents a real life ticket system that can be used in any setting. It is very versatile so it can be 
 * used with one or many helper types, can edit options, embeds, etc.
 * @class
 */
class TicketManager {

    /**
     * All the information needed for tickets in this ticket manager
     * @typedef SystemWideTicketInfo
     * @property {GarbageCollectorInfo} garbageCollectorInfo - the garbage collector information for each tickets
     * @property {Boolean} isAdvancedMode Information about the system being advanced. Advanced mode will create a category with channels for 
     * the users and the helpers. Regular will not create anything and expects the helper to DM the user or users.
     */
    /**
     * @typedef GarbageCollectorInfo
     * @property {Boolean} isEnabled - if the garbage collector is enabled for this ticket system
     * @property {Number} inactivePeriod - number of minutes a ticket channel will be inactive before bot starts to delete it
     * @property {Number} bufferTime - number of minutes the bot will wait for a response before deleting ticket
     */

    /**
     * @typedef TicketCreatorInfo
     * @property {TextChannel} channel - the channel where users can create a ticket
     * @property {Console} console - the console used to let users create tickets
     */

    /**
     * @typedef TicketDispatcherInfo
     * @property {TextChannel} channel - the channel where tickets are dispatched to 
     * @property {GuildEmoji | ReactionEmoji} takeTicketEmoji - emoji for mentors to accept/take a ticket, can be a unicode emoji string
     * @property {GuildEmoji | ReactionEmoji} joinTicketEmoji - emoji for mentors to join a taken ticket, can be a unicode emoji string
     * @property {NewTicketEmbedCreator} embedCreator - function to create a Discord MessageEmbed
     * @property {ReminderInfo} reminderInfo - the reminder information
     * @property {MainHelperInfo} mainHelperInfo
     */
    /**
     * @typedef ReminderInfo
     * @property {Boolean} isEnabled - is this feature enabled
     * @property {Number} time - how long should I wait to remind helpers
     * @property {Collection<Number, NodeJS.Timeout>} reminders - the timeout reminders mapped by the ticket ID
     */
    /**
     * @callback NewTicketEmbedCreator
     * @param {Ticket} ticket
     * @returns {MessageEmbed}
     */
    /**
     * @typedef MainHelperInfo
     * @property {Role} role
     * @property {GuildEmoji | ReactionEmoji} emoji - can be a unicode emoji string
     */


    /**
     * @constructor
     * @param {Activity} parent 
     * @param {Object} args
     * @param {TicketCreatorInfo} args.ticketCreatorInfo
     * @param {TicketDispatcherInfo} args.ticketDispatcherInfo
     * @param {SystemWideTicketInfo} args.systemWideTicketInfo
     */
    constructor(parent, { ticketCreatorInfo, ticketDispatcherInfo, systemWideTicketInfo }) {

        /**
         * The tickets in this ticket system.
         * @type {Collection<Number, Ticket>} - <Ticket Number (ID), Ticket>
         */
        this.tickets = new Collection();

        /**
         * The number of tickets created.
         * Must be separate as tickets.length since we use this to assign IDs to tickets.
         */
        this.ticketCount = 0;

        /**
         * The parent of this ticket-system. It must be paired with a cave or an activity.
         * @type {Activity}
         */
        this.parent = parent;

        /**
         * @type {TicketCreatorInfo}
         */
        this.ticketCreatorInfo = {
            channel: null,
            console: null,
        };

        /**
         * @type {TicketDispatcherInfo}
         */
        this.ticketDispatcherInfo = {
            channel: null,
            takeTicketEmoji: null,
            joinTicketEmoji: null,
            embedCreator: null, // function
            reminderInfo: {
                isEnabled: null,
                time: null,
                reminders: new Collection(),
            },
            mainHelperInfo: {
                role: null,
                emoji: null,
            },
        };

        /**
         * @type {SystemWideTicketInfo}
         */
        this.systemWideTicketInfo = {
            garbageCollectorInfo: {
                isEnabled : false,
                inactivePeriod : null,
                bufferTime : null,
            },
            isAdvancedMode: false,
        };

        /**
         * Information about the system being multi role, if its the case, it needs a 
         * Multi Role Selector.
         */
        this.multiRoleInfo = {
            isEnabled : false,
            multiRoleSelector : null,
        };

        this.validateTicketSystemInfo({ ticketCreatorInfo, ticketDispatcherInfo, systemWideTicketInfo });
    }
    /**
     * 
     * @param {Object} param0
     * @private
     */
    validateTicketSystemInfo({ ticketCreatorInfo, ticketDispatcherInfo, systemWideTicketInfo }) {
        this.ticketCreatorInfo = ticketCreatorInfo;
        this.ticketDispatcherInfo = ticketDispatcherInfo;
        this.systemWideTicketInfo = systemWideTicketInfo;
        this.ticketDispatcherInfo.reminderInfo.reminders = new Collection();
    }

    /**
     * Sends the ticket creator console.
     * @param {String} title - the ticket creator console title
     * @param {String} description - the ticket creator console description
     * @param {String} [color] - the ticket creator console color, hex
     * @async
     */
    async sendTicketCreatorConsole(title, description, color) {
        /** @type {Console.Feature[]} */
        let featureList = [
            Feature.create({
                name: 'General Ticket',
                description: 'A general ticket aimed to all helpers.',
                emoji: this.ticketDispatcherInfo.mainHelperInfo.emoji,
                callback: (user, reaction, stopInteracting, console) => this.startTicketCreationProcess(user, this.ticketDispatcherInfo.mainHelperInfo.role, console.channel).then(() => stopInteracting()),
            })
        ];

        let features = new Collection(featureList.map(feature => [feature.emojiName, feature]));

        this.ticketCreatorInfo.console = new Console({ title, description, channel: this.ticketCreatorInfo.channel, features, color, guild: this.parent.guild });
        await this.ticketCreatorInfo.console.sendConsole();
    }

    /**
     * Adds a new type of ticket, usually a more focused field, there must be a role associated 
     * to this new type of ticket.
     * @param {Role} role - role to add
     * @param {String} typeName
     * @param {GuildEmoji | ReactionEmoji} emoji 
     */
    addTicketType(role, typeName, emoji) {
        this.ticketCreatorInfo.console.addFeature(
            Feature.create({
                name: `Question about ${typeName}`,
                description: '---------------------------------',
                emoji: emoji,
                callback: (user, reaction, stopInteracting, console) => {
                    this.startTicketCreationProcess(user, role, console.channel).then(() => stopInteracting());
                }
            })
        );
    }

    /**
     * Prompts a user for more information to create a new ticket for them.
     * @param {User} user - the user creating a ticket
     * @param {Role} role 
     * @param {TextChannel | DMChannel}
     * @async
     */
    async startTicketCreationProcess(user, role, channel) {
        // check if role has mentors in it
        if (role.members.size <= 0) {
            sendMsgToChannel(channel, user.id, 'There are no mentors available with that role. Please request another role or the general role!', 10);
            winston.loggers.get(this.parent.botGuild._id).userStats(`The cave ${this.parent.name} received a ticket from user ${user.id} but was canceled due to no mentor having the role ${role.name}.`, { event: 'Ticket Manager' });
            return;
        }

        try {
            var promptMsg = await StringPrompt.single({prompt: 'Please send ONE message with: \n* A one liner of your problem ' + 
                                '\n* Mention your team members using @friendName (example: @John).', channel, userId: user.id, cancelable: true, time: 45});
        } catch (error) {
            winston.loggers.get(this.parent.botGuild._id).warning(`New ticket was canceled due to error: ${error}`, { event: 'Ticket Manager' });
            return;
        }

        let hackers = new Collection();
        hackers.set(user.id, user);
        if (promptMsg.mentions.users.size > 0) hackers = hackers.concat([promptMsg.mentions.users]);

        this.newTicket(hackers, promptMsg.cleanContent, role);
    }

    /**
     * Adds a new ticket.
     * @param {Collection<String, User>} hackers
     * @param {String} question
     * @param {Role} roleRequested
     * @private
     */
    newTicket(hackers, question, roleRequested) {
        let ticket = new Ticket(hackers, question, roleRequested, this.ticketCount, this);
        this.tickets.set(ticket.id, ticket);

        this.setReminder(ticket);

        this.ticketCount ++;

        ticket.setStatus('new');
    }

    /**
     * Sets a reminder to a ticket only if reminders are on.
     * @param {Ticket} ticket 
     * @private
     */
    setReminder(ticket) {
        // if reminders are on, set a timeout to reminder the main role of this ticket if the ticket is still new
        if (this.ticketDispatcherInfo.reminderInfo.isEnabled) {
            let timeout = setTimeout(() => {
                if (ticket.status === Ticket.STATUS.new) {
                    ticket.consoles.ticketManager.changeColor('#ff5736');
                    this.ticketDispatcherInfo.channel.send(`Hello <@&${this.ticketDispatcherInfo.mainHelperInfo.role.id}> ticket number ${ticket.id} still needs help!`)
                        .then(msg => msg.delete({ timeout: (this.ticketDispatcherInfo.reminderInfo.time * 60 * 1000)/2 }));
                    // sets another timeout
                    this.setReminder(ticket);
                }
            }, this.ticketDispatcherInfo.reminderInfo.time * 60 * 1000);

            this.ticketDispatcherInfo.reminderInfo.reminders.set(ticket.id, timeout);
        }
    }

    /**
     * Return the number of tickets in this ticket system.
     * @returns {Number}
     */
    getTicketCount() {
        return this.tickets.size;
    }

    /**
     * Removes all the tickets from this ticket manager.
     * @param {Number[]} [excludeTicketIds=[]] - tickets to be excluded
     */
    removeAllTickets(excludeTicketIds = []) {
        // exclude the tickets
        let ticketsToRemove;
        if (excludeTicketIds.length > 0) ticketsToRemove = this.tickets.filter((ticket, ticketId) => excludeTicketIds.includes(ticketId));
        else ticketsToRemove = this.tickets;

        ticketsToRemove.forEach((ticket, ticketId) => {
            this.removeTicket(ticketId);
        });
    }

    /**
     * Removes tickets by their ids
     * @param {Number[]} ticketIds - ticket ids to remove
     */
    removeTicketsById(ticketIds) {
        ticketIds.forEach(ticketId => {
            this.removeTicket(ticketId);
        });
    }

    /**
     * Removes all tickets older than the given age.
     * @param {Number} minAge - the minimum age in minutes
     * @throws Error when used and advanced mode is turned off
     */
    removeTicketsByAge(minAge) {
        // only usable when advanced mode is turned on
        if (!this.systemWideTicketInfo.isAdvancedMode) throw new Error('Remove by age is only available when advanced mode is on!');
        this.tickets.forEach((ticket, ticketId, tickets) => {
            let now = Date.now();

            let timeDif = now - ticket.room.timeCreated;

            if (timeDif > minAge * 50 * 1000) {
                this.removeTicket(ticketId);
            }
        });
    }

    /**
     * Removes a ticket, deletes the ticket's channels too!
     * @param {Number} ticketId - the ticket id to remove
     */
    removeTicket(ticketId) {
        // remove the reminder for this ticket if reminders are on
        if (this.ticketDispatcherInfo.reminderInfo.isEnabled && this.ticketDispatcherInfo.reminderInfo.reminders.has(ticketId)) {
            clearTimeout(this.ticketDispatcherInfo.reminderInfo.reminders.get(ticketId));
            this.ticketDispatcherInfo.reminderInfo.reminders.delete(ticketId);
        }
        this.tickets.get(ticketId).setStatus(Ticket.STATUS.closed, 'ticket manager closed the ticket');
    }
}
module.exports = TicketManager;