Source

classes/room.js

const winston = require('winston');
const { GuildCreateChannelOptions, User, Guild, Collection, Role, CategoryChannel, VoiceChannel, TextChannel, OverwriteResolvable, PermissionOverwriteOption } = require('discord.js');
const BotGuildModel = require('./bot-guild');
const { deleteChannel } = require('../discord-services');

/**
 * @typedef RoomChannels
 * @property {CategoryChannel} category
 * @property {TextChannel} generalText
 * @property {VoiceChannel} generalVoice
 * @property {TextChannel} nonLockedChannel
 * @property {Collection<String, VoiceChannel>} voiceChannels - <Channel ID, channel>
 * @property {Collection<String, TextChannel>} textChannels - <Channel ID, channel>
 * @property {Collection<String, TextChannel | VoiceChannel>} safeChannels - <Channel ID, channel>, channels that can not be removed
 */

/**
 * An object with a role and its permissions
 * @typedef RolePermission
 * @property {String} id - the role snowflake
 * @property {PermissionOverwriteOption} permissions - the permissions to set to that role
 */

/**
 * The room class represents a room where things can occur, a room 
 * consists of a category with voice and text channels. As well as roles 
 * or users allowed to see the room.
 */
class Room {

    /**
     * @param {Guild} guild - the guild in which the room lives
     * @param {BotGuildModel} botGuild - the botGuild
     * @param {String} name - name of the room 
     * @param {Collection<String, Role >} [rolesAllowed=Collection()] - the participants able to view this room
     * @param {Collection<String, User>} [usersAllowed=Collection()] - the individual users allowed to see the room
     */
    constructor(guild, botGuild, name, rolesAllowed = new Collection(), usersAllowed = new Collection()) {

        /**
         * The name of this room. Will remove all leading and trailing whitespace and
         * switch spaces for '-'. Will also replace all character except for numbers, letters and '-' 
         * and make it lowercase.
         * @type {string}
         */
        this.name = name.split(' ').join('-').trim().replace(/[^0-9a-zA-Z-]/g, '').toLowerCase();

        /**
         * The guild this activity is in.
         * @type {Guild}
         */
        this.guild = guild;

        /**
         * Roles allowed to view the room.
         * @type {Collection<String, Role>}
         */
        this.rolesAllowed = rolesAllowed;

        /**
         * Users allowed to view the room.
         * @type {Collection<String, User>}
         */
        this.usersAllowed = usersAllowed;

        /**
         * All the channels this room has!
         * @type {RoomChannels}
         */
        this.channels = {
            category: null,
            generalVoice: null,
            generalText: null,
            nonLockedChannel: null,
            voiceChannels: new Collection(),
            textChannels: new Collection(),
            safeChannels: new Collection(),
        };

        /**
         * The mongoose BotGuildModel Object
         * @type {BotGuildModel}
         */
        this.botGuild = botGuild;

        /**
         * True if the room is locked, false otherwise.
         * @type {Boolean}
         */
        this.locked = false;

        /**
         * The time this room was created.
         * @type {Number}
         */
        this.timeCreated = Date.now();

    }

    /**
     * Initialize this activity by creating the channels, adding the features and sending the admin console.
     * @param {Object} [args] - channels already created to add to this room
     * @param {CategoryChannel} args.category
     * @param {TextChannel} args.textChannel
     * @param {VoiceChannel} args.voiceChannel
     * @async
     * @returns {Promise<Room>}
     */
    async init(args) {
        if (args?.category) this.channels.category = args.category;
        else {
            let position = this.guild.channels.cache.filter(channel => channel.type === 'category').size;
            this.channels.category = await this.createCategory(position);
        }

        if (args?.textChannel) {
            this.channels.generalText = args.textChannel;
            this.addExcisingChannel(args.textChannel);
        }
        else this.channels.generalText = await this.addRoomChannel({
            name: this.name.length < 12 ? `${this.name}-banter` : Room.mainTextChannelName, 
            info: {
                parent: this.channels.category,
                type: 'text',
                topic: 'A general banter channel to be used to communicate with other members, mentors, or staff. The !ask command is available for questions.',
            }
        });

        if (args?.voiceChannel) {
            this.channels.generalVoice = args.voiceChannel;
            this.addExcisingChannel(args.voiceChannel);
        }
        else this.channels.generalVoice = await this.addRoomChannel({
            name: this.name.length < 12 ? `${this.name}-room` : Room.mainVoiceChannelName, 
            info: {
                parent: this.channels.category,
                type: 'voice',
            }
        });

        winston.loggers.get(this.guild.id).event(`The room ${this.name} was initialized.`, {event: 'Room'});
        return this;
    }
    /**
     * Helper function to create the category 
     * @param {Number} position - the position of this category on the server
     * @returns {Promise<CategoryChannel>} - a category with the activity name
     * @async
     * @private
     */
    async createCategory(position) {
        /** @type {OverwriteResolvable[]} */
        let overwrites = [
            {
                id: this.botGuild.roleIDs.everyoneRole,
                deny: ['VIEW_CHANNEL'],
            }];
        this.rolesAllowed.each(role => overwrites.push({ id: role.id, allow: ['VIEW_CHANNEL'] }));
        this.usersAllowed.each(user => overwrites.push({ id: user.id, allow: ['VIEW_CHANNEL'] }));
        return this.guild.channels.create(this.name, {
            type: 'category',
            position: position >= 0 ? position : 0,
            permissionOverwrites: overwrites
        });
    }

    /**
     * Adds a channels to the room.
     * @param {Object} args
     * @param {String} args.name - name of the channel to create
     * @param {GuildCreateChannelOptions} [args.info={}] - one of voice or text
     * @param {RolePermission[]} [args.permissions=[]] - the permissions per role to be added to this channel after creation.
     * @param {Boolean} [args.isSafe=false] - true if the channel is safe and cant be removed
     * @async
     */
    async addRoomChannel({name, info = {}, permissions = [], isSafe = false}) {
        info.parent = info?.parent || this.channels.category;
        info.type = info?.type || 'text';

        let channel = await this.guild.channels.create(name, info);

        permissions.forEach(rolePermission => channel.updateOverwrite(rolePermission.id, rolePermission.permissions));

        // add channel to correct list
        if (info.type == 'text') this.channels.textChannels.set(channel.id, channel);
        else this.channels.voiceChannels.set(channel.id, channel);

        if (isSafe) this.channels.safeChannels.set(channel.id, channel);

        winston.loggers.get(this.guild.id).event(`The activity ${this.name} had a channel named ${name} added to it of type ${info?.type || 'text'}.`, {event: 'Activity'});

        return channel;
    }

    /**
     * Removes a channel from the room.
     * @param {VoiceChannel | TextChannel} channelToRemove 
     * @param {Boolean} [isForced=false] - is the deletion forced?, if so then channel will be removed even if its safeChannels
     * @async
     */
    async removeRoomChannel(channelToRemove, isForced = false) {
        if (isForced && this.channels.safeChannels.has(channelToRemove.id)) throw Error('Can\'t remove that channel.');

        if (channelToRemove.type === 'text') this.channels.textChannels.delete(channelToRemove.id);
        else this.channels.voiceChannels.delete(channelToRemove.id);

        this.channels.safeChannels.delete(channelToRemove.id);

        deleteChannel(channelToRemove);
        winston.loggers.get(this.guild.id).event(`The room ${this.name} lost a channel named ${channelToRemove.name}`, { event: 'Room' });
    }

    /**
     * Deletes the room.
     * @async
     */
    async delete() {
        // only delete channels if they were created!
        if (this.channels?.category) {
            var listOfChannels = this.channels.category.children.array();
            for (var i = 0; i < listOfChannels.length; i++) {
                await deleteChannel(listOfChannels[i]);
            }

            await deleteChannel(this.channels.category);
        }
        winston.loggers.get(this.guild.id).event(`The room ${this.name} was deleted!`, {event: "Room"});
    }

    /**
     * Archive the activity. Move general text channel to archive category, remove all remaining channels
     * and remove the category.
     * @param {CategoryChannel} archiveCategory - the category where the general text channel will be moved to
     * @async
     */
    async archive(archiveCategory) {
        // move all text channels to the archive and rename with activity name
        // remove all voice channels in the category one at a time to not get a UI glitch

        this.channels.category.children.forEach(async (channel, key) => {
            this.botGuild.blackList.delete(channel.id);
            if (channel.type === 'text') {
                let channelName = channel.name;
                await channel.setName(`${this.name}-${channelName}`);
                await channel.setParent(archiveCategory);
            } else deleteChannel(channel);
        });

        await deleteChannel(this.channels.category);

        this.botGuild.save();

        winston.loggers.get(this.guild.id).event(`The activity ${this.name} was archived!`, {event: 'Activity'});
    }

    /**
     * Locks the room for all roles except for a text channel. To gain access users must be allowed access
     * individually.
     * @returns {Promise<TextChannel>} - channel available to roles
     */
    async lockRoom() {
        // set category private
        this.rolesAllowed.forEach((role, key) => this.channels.category.updateOverwrite(role, { VIEW_CHANNEL: false }));

        /** @type {TextChannel} */
        this.channels.nonLockedChannel = await this.addRoomChannel({
            name: 'Activity Rules START HERE', 
            info: { type: 'text' }, 
            permissions: this.rolesAllowed.map((role, key) => ({ id: role.id, permissions: { VIEW_CHANNEL: true, SEND_MESSAGES: false, }})), 
            isSafe: true});
        this.channels.safeChannels.set(this.channels.nonLockedChannel.id, this.channels.nonLockedChannel);

        this.locked = true;

        return this.channels.nonLockedChannel;
    }

    /**
     * Gives access to the room to a role.
     * @param {Role} role - role to give access to
     */
    giveRoleAccess(role) {
        this.rolesAllowed.set(role.id, role);

        if (this.locked) {
            this.channels.nonLockedChannel.updateOverwrite(role.id, { VIEW_CHANNEL: true, SEND_MESSAGES: false });
        } else {
            this.channels.category.updateOverwrite(role.id, { VIEW_CHANNEL: true });
        }
    }

    /**
     * Gives access to a user
     * @param {User} user - user to give access to
     */
    giveUserAccess(user) {
        this.usersAllowed.set(user.id, user);
        this.channels.category.updateOverwrite(user.id, { VIEW_CHANNEL: true, SEND_MESSAGES: true });
    }

    /**
     * Removes access to a user to see this room.
     * @param {User} user - the user to remove access to
     */
    removeUserAccess(user) {
        this.usersAllowed.delete(user.id);
        this.channels.category.updateOverwrite(user.id, { VIEW_CHANNEL: false });
    }

    /**
     * @param {TextChannel | VoiceChannel} channel
     */
    addExcisingChannel(channel) {
        if (channel.type === 'text') this.channels.textChannels.set(channel.id, channel);
        else if (channel.type === 'voice') this.channels.voiceChannels.set(channel.id, channel);
    }
}

Room.voiceChannelName = '🔊Room-';
Room.mainTextChannelName = '🖌️activity-banter';
Room.mainVoiceChannelName = '🗣️activity-room';

module.exports = Room;