Source

lib/ihlManager.js

/* eslint-disable class-methods-use-this */
/**
 * @module ihlManager
 */

/**
 * Node.js EventEmitter object
 * @external EventEmitter
 * @category Other
 * @see {@link https://nodejs.org/api/events.html#events_class_eventemitter}
 */

const Dota2 = require('dota2');
const assert = require('assert').strict;
const Commando = require('discord.js-commando');
const Permissions = require('discord.js/src/util/Permissions.js');
const Promise = require('bluebird');
const { EventEmitter } = require('events');
const path = require('path');
const util = require('util');
const logger = require('./logger');
const MatchTracker = require('./matchTracker');
const CONSTANTS = require('./constants');
const Db = require('./db');
const Lobby = require('./lobby');
const Ihl = require('./ihl');
const Fp = require('./util/fp');
const equalsLong = require('./util/equalsLong');
const Guild = require('./guild');
const DotaBot = require('./dotaBot');
const LobbyStateHandlers = require('./lobbyStateHandlers');
const LobbyQueueHandlers = require('./lobbyQueueHandlers');
const EventListeners = require('./eventListeners');
const MessageListeners = require('./messageListeners');

/**
* Searches the discord guild for a member.
* @function
* @param {external:Guild} guild - A list of guilds to initialize leagues with.
* @param {string|external:GuildMember} member - A name or search string for an inhouse player or their guild member instance.
* @returns {Array} The search result in an array containing the user record, discord guild member, and type of match.
*/
const findUser = guild => async (member) => {
    let discordId;
    let discorduser;
    let user;
    let resultType;
    const discordIdMatches = `${member}`.match(/<@!?(\d+)>/);
    logger.silly(`findUser ${util.inspect(member)} ${discordIdMatches}`);
    if (discordIdMatches) {
        discordId = discordIdMatches[1];
        discorduser = guild.members.get(discordId);
        user = await Db.findUserByDiscordId(guild.id)(discordId);
        resultType = CONSTANTS.MATCH_EXACT_DISCORD_MENTION;
    }
    else {
        // check exact discord displayName match
        discorduser = guild.members.find(guildMember => guildMember.displayName.toLowerCase() === member.toLowerCase());
        if (discorduser) {
            logger.silly(`findUser matched on displayName exact ${member.toLowerCase()}`);
            discordId = discorduser.id;
            user = await Db.findUserByDiscordId(guild.id)(discordId);
            resultType = CONSTANTS.MATCH_EXACT_DISCORD_NAME;
        }
        else {
            // check exact discord username match
            discorduser = guild.members.find(guildMember => guildMember.user.username.toLowerCase() === member.toLowerCase());
            if (discorduser) {
                logger.silly(`findUser matched on username exact ${member.toLowerCase()}`);
                discordId = discorduser.id;
                user = await Db.findUserByDiscordId(guild.id)(discordId);
                resultType = CONSTANTS.MATCH_EXACT_DISCORD_NAME;
            }
            else {
                // check exact nickname match
                user = await Db.findUserByNickname(guild.id)(member);
                if (user) {
                    logger.silly(`findUser matched on user nickname exact ${user.nickname}`);
                    discordId = user.discordId;
                    discorduser = guild.members.get(discordId);
                    resultType = CONSTANTS.MATCH_EXACT_NICKNAME;
                }
                else {
                    // try to parse a steamId64 from text
                    const steamId64 = await Ihl.parseSteamID64(member);
                    if (steamId64 != null) {
                        user = await Db.findUserBySteamId64(guild.id)(steamId64);
                        logger.silly(`findUser matched on steamId64 ${steamId64} ${user}`);
                    }
                    if (user) {
                        discorduser = guild.members.get(user.discordId);
                        resultType = CONSTANTS.MATCH_STEAMID_64;
                    }
                    else {
                        // check close nickname match
                        try {
                            [user] = await Db.findUserByNicknameLevenshtein(guild.id)(member);
                            if (user) {
                                logger.silly(`findUser matched on user nickname approximate ${user.nickname}`);
                                discordId = user.discordId;
                                discorduser = guild.members.get(discordId);
                                resultType = CONSTANTS.MATCH_CLOSEST_NICKNAME;
                            }
                        }
                        catch (e) {
                            logger.error(e);
                        }
                    }
                }
            }
        }
    }
    logger.silly(`findUser ${user ? user.id : null} ${discorduser ? discorduser.id : null} ${resultType}`);
    if (user && discorduser) {
        return [user, discorduser, resultType];
    }
    logger.silly('findUser not found');
    return [null, null, null];
};

/**
* Maps league records to inhouse states.
* @function
* @async
* @param {external:Guild[]} guilds - A list of guilds to initialize leagues with.
* @param {module:db.League[]} leagues - A list of database league records.
* @returns {module:ihl.InhouseState[]} The inhouse states loaded from league records.
*/
const loadInhouseStates = guilds => async leagues => Fp.mapPromise(Ihl.createInhouseState)(leagues.map(league => ({ league, guild: guilds.get(league.guildId) })).filter(o => o.guild));

/**
* Gets all league records from the database turns them into inhouse states.
* @function
* @async
* @param {external:Guild[]} guilds - A list of guilds to initialize leagues with.
* @returns {module:ihl.InhouseState[]} The inhouse states loaded from all league records.
*/
const loadInhouseStatesFromLeagues = async guilds => Fp.pipeP(
    Db.findAllLeagues,
    loadInhouseStates(guilds),
)();

const createClient = options => new Commando.CommandoClient({
    commandPrefix: options.COMMAND_PREFIX || '!',
    owner: options.OWNER_DISCORD_ID,
    disableEveryone: true,
    commandEditableDuration: 0,
    unknownCommandResponse: false,
});

const setMatchPlayerDetails = matchOutcome => members => async (_lobby) => {
    const lobby = await Lobby.getLobby(_lobby);
    const players = await Lobby.getPlayers()(lobby);
    let winner = 0;
    const tasks = [];
    for (const playerData of members) {
        const steamId64 = playerData.id.toString();
        const player = players.find(p => p.steamId64 === steamId64);
        if (player) {
            const data = {
                win: 0,
                lose: 0,
                heroId: playerData.hero_id,
                kills: 0,
                deaths: 0,
                assists: 0,
                gpm: 0,
                xpm: 0,
            };
            if (((playerData.team === Dota2.schema.DOTA_GC_TEAM.DOTA_GC_TEAM_GOOD_GUYS)
                 && (matchOutcome === Dota2.schema.EMatchOutcome.k_EMatchOutcome_RadVictory))
                || ((playerData.team === Dota2.schema.DOTA_GC_TEAM.DOTA_GC_TEAM_BAD_GUYS)
                 && (matchOutcome === Dota2.schema.EMatchOutcome.k_EMatchOutcome_DireVictory))) {
                data.win = 1;
                winner = player.LobbyPlayer.faction;
            }
            else {
                data.lose = 1;
            }
            tasks.push(Lobby.updateLobbyPlayerBySteamId(data)(_lobby)(steamId64));
        }
    }
    await Fp.allPromise(tasks);
    await Db.updateLobbyWinner(lobby)(winner);
};

class IHLManager extends EventEmitter {
    /**
     * Creates an inhouse league manager.
     * @classdesc Class representing the inhouse league manager.
     */
    constructor(options) {
        super();

        this.options = options;
        this.client = null;
        this.lobbyTimeoutTimers = {};
        this.bots = {};
        this.matchTracker = null;
        this.attachListeners();
        this.eventQueue = [];
        this.blocking = false;
        this._runningLobby = false;
    }

    static get botPermissions() {
        return [
            'MANAGE_ROLES',
            'MANAGE_CHANNELS',
            'VIEW_CHANNEL',
            'SEND_MESSAGES',
            'EMBED_LINKS',
        ];
    }

    static get inviteUrl() {
        // eslint-disable-next-line no-bitwise
        return `https://discordapp.com/oauth2/authorize/?permissions=${IHLManager.botPermissions.reduce((all, p) => all | Permissions.FLAGS[p], 0)}&scope=bot&client_id=${process.env.CLIENT_ID}`;
    }

    /**
     * Initializes the inhouse league manager with a discord client and loads inhouse states for each league.
     * @async
     * @param {external:Client} client - A discord.js client.
     */
    async init(client) {
        logger.debug(`ihlManager init ${this.options.COMMAND_PREFIX}`);
        this.matchTracker = new MatchTracker.MatchTracker(parseInt(this.options.MATCH_POLL_INTERVAL || 5000));
        this.matchTracker.on(CONSTANTS.EVENT_MATCH_STATS, lobby => this[CONSTANTS.EVENT_MATCH_STATS](lobby).catch(e => logger.error(e)));
        this.matchTracker.on(CONSTANTS.EVENT_MATCH_NO_STATS, lobby => this[CONSTANTS.EVENT_MATCH_NO_STATS](lobby).catch(e => logger.error(e)));
        this.client = client;
        this.client.ihlManager = this;
        this.client.registry
            .registerDefaultTypes()
            .registerGroups([
                ['ihl', 'Inhouse League General Commands'],
                ['queue', 'Inhouse League Queue Commands'],
                ['challenge', 'Inhouse League Challenge Commands'],
                ['admin', 'Inhouse League Admin Commands'],
                ['owner', 'Bot Owner Commands'],
            ])
            .registerDefaultGroups()
            .registerDefaultCommands()
            .registerCommandsIn(path.join(__dirname, '../commands'));
        this.client.on('message', this.onDiscordMessage.bind(this));
        this.client.on('guildMemberRemove', this.onDiscordMemberLeave.bind(this));
        this.client.on('commandError', (cmd, err) => {
            if (err instanceof Commando.FriendlyError) return;
            logger.error(err);
        });
        return new Promise((resolve, reject) => {
            try {
                this.client.on('ready', async () => {
                    await this.onClientReady();
                    resolve();
                });
                this.client.login(this.options.TOKEN);
            }
            catch (e) {
                logger.error(e);
                reject(e);
            }
        });
    }

    get guilds() {
        return this.client.guilds.filter(guild => guild.me.hasPermission(IHLManager.botPermissions));
    }


    /**
     * IHLManager ready event.
     *
     * @event module:ihlManager~ready
     */

    /**
    * Discord client ready handler.
    * @async
    * @fires module:ihlManager~ready
    */
    async onClientReady() {
        logger.debug(`ihlManager onClientReady logged in as ${this.client.user.tag}`);
        await Db.setAllBotsOffline();
        const inhouseStates = await loadInhouseStatesFromLeagues(this.guilds);
        logger.silly('ihlManager inhouseStates loaded');
        await Fp.mapPromise(Ihl.createLobbiesFromQueues)(inhouseStates);
        logger.silly('ihlManager created lobbies from queues');
        await Fp.mapPromise(this.runLobbiesForInhouse.bind(this))(inhouseStates);
        await this.matchTracker.loadLobbies();
        this.matchTracker.run();
        logger.debug('ihlManager onClientReady lobbies loaded and run.');
        this.emit('ready');
    }

    /**
    * Discord message handler.
    * @async
    * @param {external:discordjs.Message}  msg - The discord message.
    */
    async onDiscordMessage(msg) {
        if (msg.author.id !== this.client.user.id) {
            logger.silly(`ihlManager onDiscordMessage ${msg.channel.guild} ${msg.channel.id} ${msg.author.id} ${msg.content}`);
            if (msg.channel.guild) {
                this[CONSTANTS.EVENT_GUILD_MESSAGE](msg).catch(e => logger.error(e));
            }
        }
    }

    /**
    * Discord user left guild handler.
    * @async
    * @param {external:discordjs.GuildMember} member - The member that left.
    */
    async onDiscordMemberLeave(member) {
        logger.debug(`ihlManager onDiscordMemberLeave ${member}`);
        const user = await Db.findUserByDiscordId(member.guild.id)(member.id);
        if (user) {
            this[CONSTANTS.EVENT_GUILD_USER_LEFT](user).catch(e => logger.error(e));
        }
    }

    /**
     * Creates a new inhouse in a discord guild.
     * @async
     * @param {external:discordjs.Guild} guild - The discord guild for the inhouse.
     */
    async createNewLeague(guild) {
        logger.debug(`ihlManager createNewLeague ${guild.id}`);
        const inhouseState = await Ihl.createNewLeague(guild);
        await Ihl.createLobbiesFromQueues(inhouseState);
        await this.runLobbiesForInhouse(inhouseState);
    }

    /**
     * Creates and runs a challenge lobby.
     * @async
     * @param {module:ihl.inhouseState} inhouseState - The inhouse state.
     * @param {module:db.User} captain1 - The first lobby captain.
     * @param {module:db.User} captain2 - The second lobby captain.
     * @param {module:db.Challenge} challenge - The challenge between the two captains.
     */
    async createChallengeLobby(inhouseState, captain1, captain2, challenge) {
        logger.debug(`ihlManager createChallengeLobby ${inhouseState.guild.id} ${captain1.id} ${captain2.id}`);
        const lobbyState = await Lobby.createChallengeLobby({ inhouseState, captain1, captain2, challenge });
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_NEW]).catch(e => logger.error(e));
    }

    /**
    * Runs all lobbies for an inhouse.
    * @async
    * @param {module:ihl.inhouseState} inhouseState - The inhouse state.
    */
    async runLobbiesForInhouse(inhouseState) {
        logger.silly(`ihlManager runLobbiesForInhouse ${inhouseState.guild.id}`);
        const lobbies = await Db.findAllActiveLobbiesForInhouse(inhouseState.guild.id);
        return Fp.allPromise(lobbies.map(lobby => Fp.pipeP(
            Lobby.lobbyToLobbyState(inhouseState),
            Lobby.resetLobbyState,
        )(lobby).then((lobbyState) => {
            this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [lobbyState.state]).catch(e => logger.error(e));
        })));
    }

    /**
     * Adds a user to a lobby queue.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - The lobby to join.
     * @param {module:db.User} user - The user to queue.
     * @param {external:discordjs.User} discordUser - A discord.js user.
     */
    async joinLobbyQueue(lobbyState, user, discordUser) {
        logger.silly(`ihlManager joinLobbyQueue ${lobbyState.id} ${user.id} ${discordUser.id}`);
        const result = await Ihl.joinLobbyQueue(user)(lobbyState);
        logger.silly(`ihlManager joinLobbyQueue result ${result}`);
        if (result) {
            this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_WAITING_FOR_QUEUE]).catch(e => logger.error(e));
        }
        return result;
    }

    /**
     * Adds a user to all lobby queues.
     * @async
     * @param {module:ihl.InhouseState} inhouseState - The inhouse to queue.
     * @param {module:db.User} user - The user to queue.
     * @param {external:discordjs.User} discordUser - A discord.js user.
     */
    async joinAllLobbyQueues(inhouseState, user, discordUser) {
        logger.silly(`ihlManager joinAllLobbyQueues ${inhouseState.guild.id} ${user.id} ${discordUser.id}`);
        const lobbyStates = await Ihl.getAllLobbyQueues(inhouseState);
        await Fp.allPromise(lobbyStates.map(lobbyState => this.joinLobbyQueue(lobbyState, user, discordUser)));
    }

    /**
     * Removes a user from a lobby queue.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - The lobby to join.
     * @param {module:db.User} user - The user to dequeue.
     * @param {external:discordjs.User} discordUser - A discord.js user.
     */
    async leaveLobbyQueue(lobbyState, user, discordUser) {
        logger.silly(`ihlManager leaveLobbyQueue ${lobbyState.id} ${user.id} ${discordUser.id}`);
        const inQueue = await Ihl.leaveLobbyQueue(user)(lobbyState);
        if (inQueue) {
            this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState).catch(e => logger.error(e));
        }
        return inQueue;
    }

    /**
     * Removes a user from all lobby queues.
     * @async
     * @param {module:ihl.InhouseState} inhouseState - The inhouse to dequeue.
     * @param {module:db.User} user - The user to dequeue.
     * @param {external:discordjs.User} discordUser - A discord.js user.
     */
    async leaveAllLobbyQueues(inhouseState, user, discordUser) {
        logger.silly(`ihlManager leaveAllLobbyQueues ${inhouseState.guild.id} ${user.id} ${discordUser.id}`);
        const lobbyStates = await Ihl.getAllLobbyQueuesForUser(inhouseState, user);
        await Fp.allPromise(lobbyStates.map(lobbyState => this.leaveLobbyQueue(lobbyState, user, discordUser)));
    }

    /**
     * Clear a lobby queue.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - The lobby queue to clear.
     */
    async clearLobbyQueue(lobbyState) {
        logger.silly(`ihlManager clearLobbyQueue ${lobbyState.id}`);
        await Db.destroyLobbyQueuers(lobbyState);
        await Lobby.setLobbyTopic(lobbyState);
    }

    /**
     * Clear all lobby queues.
     * @async
     * @param {module:ihl.InhouseState} inhouseState - The inhouse queue to clear.
     */
    async clearAllLobbyQueues(inhouseState) {
        const lobbies = await Db.findAllLobbiesForInhouse(inhouseState);
        logger.silly(`ihlManager clearAllLobbyQueues ${inhouseState.guild.id} ${lobbies.length}`);
        await Fp.mapPromise(Db.destroyLobbyQueuers)(lobbies);
        const lobbyStates = await Fp.mapPromise(Ihl.loadLobbyState(this.client.guilds))(lobbies);
        await Fp.mapPromise(Lobby.setLobbyTopic)(lobbyStates);
    }

    /**
     * Bans a user from the inhouse queue.
     * @async
     * @param {module:ihl.InhouseState} inhouseState - The inhouse to dequeue.
     * @param {module:db.User} user - The player to ban.
     * @param {number} timeout - Duration of ban in minutes.
     * @param {external:discordjs.User} discordUser - A discord.js user.
     */
    async banInhouseQueue(inhouseState, user, timeout, discordUser) {
        logger.silly(`ihlManager banInhouseQueue ${inhouseState.guild.id} ${user.id} ${timeout} ${discordUser.id}`);
        await Ihl.banInhouseQueue(user, timeout);
        await this.leaveAllLobbyQueues(inhouseState, user, discordUser);
    }

    /**
     * Creates and registers a ready up timer for a lobby state.
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     */
    registerLobbyTimeout(lobbyState) {
        this.unregisterLobbyTimeout(lobbyState);
        const delay = Math.max(0, lobbyState.readyCheckTime + lobbyState.inhouseState.readyCheckTimeout - Date.now());
        logger.silly(`ihlManager registerLobbyTimeout ${lobbyState.id} ${lobbyState.readyCheckTime} ${lobbyState.inhouseState.readyCheckTimeout}. timeout ${delay}ms`);
        this.lobbyTimeoutTimers[lobbyState.id] = setTimeout(() => {
            logger.silly(`ihlManager registerLobbyTimeout ${lobbyState.id} timed out`);
            this.queueEvent(this.onLobbyTimedOut, [lobbyState]);
        }, delay);
        this[CONSTANTS.MSG_READY_CHECK_START](lobbyState).catch(e => logger.error(e));
    }

    /**
     * Clears and unregisters the ready up timer for a lobby state.
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     */
    unregisterLobbyTimeout(lobbyState) {
        logger.silly(`ihlManager unregisterLobbyTimeout ${lobbyState.id}`);
        if (this.lobbyTimeoutTimers[lobbyState.id]) {
            clearTimeout(this.lobbyTimeoutTimers[lobbyState.id]);
        }
        delete this.lobbyTimeoutTimers[lobbyState.id];
    }

    /**
     * Processes and executes a lobby state if it matches any of the given states.
     * If no states to match against are given, the lobby state is run by default.
     * @async
     * @param {module:lobby.LobbyState} _lobbyState - An inhouse lobby state.
     * @param {string[]} states - A list of valid lobby states.
     */
    async runLobby(_lobbyState, states = []) {
        logger.silly(`ihlManager runLobby ${_lobbyState.id} ${_lobbyState.state} ${states.join(',')}`);
        let lobbyState;
        try {
            assert.equal(this._runningLobby, false, 'Already running a lobby.');
            this._runningLobby = true;
            const lobby = await Lobby.getLobby(_lobbyState);
            logger.silly(`ihlManager runLobby lobby.state ${lobby.state}`);
            if (!states.length || states.indexOf(lobby.state) !== -1) {
                lobbyState = await Fp.pipeP(
                    Lobby.createLobbyState(_lobbyState.inhouseState)(_lobbyState),
                    Lobby.validateLobbyPlayers,
                )(lobby);
                let beginState = -1;
                let endState = lobbyState.state;
                while (beginState !== endState) {
                    beginState = lobbyState.state;
                    // eslint-disable-next-line no-await-in-loop
                    lobbyState = await this[beginState](lobbyState);
                    endState = lobbyState.state;
                    logger.silly(`runLobby ${lobbyState.id} ${beginState} to ${endState}`);
                    this.emit(beginState, lobbyState); // test hook event
                }
                try {
                    await Lobby.setLobbyTopic(lobbyState);
                }
                catch (e) {
                    logger.error(e);
                }
            }
        }
        catch (e) {
            logger.error(e);
            if (lobbyState) {
                await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_FAILED);
            }
            process.exit(1);
        }
        finally {
            this._runningLobby = false;
        }
    }

    /**
     * Creates a queue lobby.
     * @async
     * @param {module:lobby.LobbyState} _lobbyState - The lobby state to create the queue from.
     */
    async onCreateLobbyQueue(_lobbyState) {
        logger.silly(`ihlManager onCreateLobbyQueue ${_lobbyState.id}`);
        const queue = await Db.findQueue(_lobbyState.leagueId, true, _lobbyState.queueType);
        if (queue && queue.queueType !== CONSTANTS.QUEUE_TYPE_CHALLENGE) {
            logger.silly(`ihlManager onCreateLobbyQueue queue ${queue.queueType}`);
            const lobbyState = await Ihl.createLobby(_lobbyState.inhouseState)(queue);
            await Guild.setChannelPosition(1)(lobbyState.channel);
            this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_NEW]).catch(e => logger.error(e));
        }
    }

    /**
     * Sets a lobby state.
     * @async
     * @param {module:lobby.LobbyState} _lobbyState - The lobby state being changed.
     * @param {string} state - The state to set the lobby to.
     */
    async onSetLobbyState(_lobbyState, state) {
        logger.silly(`ihlManager onSetLobbyState ${_lobbyState.id} ${state}`);
        const lobbyState = { ..._lobbyState, state };
        await Db.updateLobbyState(lobbyState)(state);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [state]).catch(e => logger.error(e));
    }

    /**
     * Sets a bot status.
     * @async
     * @param {string} steamId64 - Bot steam id.
     * @param {string} status - Bot status.
     */
    async onSetBotStatus(steamId64, status) {
        logger.silly(`ihlManager onSetBotStatus ${steamId64} ${status}`);
        await Db.updateBot(steamId64)({ status });
    }

    /**
     * Associates a league with a ticket.
     * @async
     * @param {module:db.League} league - The league to add the ticket to.
     * @param {number} leagueid - The ticket league id.
     * @param {string} name - The ticket name.
     */
    async onLeagueTicketAdd(league, leagueid, name) {
        const ticket = await Db.upsertTicket({ leagueid, name });
        logger.silly(`ihlManager onLeagueTicketAdd ${league.id} ${leagueid} ${name} ${util.inspect(ticket)}`);
        if (ticket) {
            await Db.addTicketOf(league)(ticket);
        }
    }

    /**
     * Sets the league ticket.
     * @async
     * @param {module:db.League} league - The league to set the ticket to.
     * @param {number} leagueid - The ticket league id.
     * @returns {module:db.Ticket}
     */
    async onLeagueTicketSet(league, leagueid) {
        const tickets = await Db.getTicketsOf({ where: { leagueid } })(league);
        logger.silly(`ihlManager onLeagueTicketSet ${league.id} ${leagueid} ${util.inspect(tickets)}`);
        if (tickets.length) {
            await Db.updateLeague(league.guildId)({ leagueid });
            return tickets[0];
        }
        return null;
    }

    /**
     * Removes a ticket from a league.
     * @async
     * @param {module:db.League} league - The league to remove the ticket from.
     * @param {number} leagueid - The ticket league id.
     */
    async onLeagueTicketRemove(league, leagueid) {
        const ticket = await Db.findTicketByDotaLeagueId(leagueid);
        logger.silly(`ihlManager onLeagueTicketRemove ${league.id} ${leagueid} ${util.inspect(ticket)}`);
        if (ticket) {
            await Db.removeTicketOf(league)(ticket);
        }
    }

    /**
     * Runs lobbies waiting for bots.
     * @async
     */
    async onBotAvailable() {
        const lobbies = await Db.findAllLobbiesInState(CONSTANTS.STATE_WAITING_FOR_BOT);
        logger.silly(`ihlManager onBotAvailable ${lobbies.length}`);
        const lobbyStates = await Fp.mapPromise(Ihl.loadLobbyState(this.client.guilds))(lobbies);
        lobbyStates.forEach(lobbyState => this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_WAITING_FOR_BOT]).catch(e => logger.error(e)));
    }

    /**
     * Set bot idle then call onBotAvailable to run lobbies waiting for bots.
     * @async
     */
    async onBotLobbyLeft(botId) {
        await Db.updateBotStatus(CONSTANTS.BOT_IDLE)(botId);
        return this.onBotAvailable();
    }

    /**
     * Runs a lobby state when its ready up timer has expired.
     * Checks for STATE_CHECKING_READY lobby state
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     */
    async onLobbyTimedOut(lobbyState) {
        logger.silly(`ihlManager onLobbyTimedOut ${lobbyState.id}`);
        delete this.lobbyTimeoutTimers[lobbyState.id];
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_CHECKING_READY]).catch(e => logger.error(e));
    }

    /**
     * Runs a lobby state when a player has readied up and update their player ready state.
     * Checks for STATE_CHECKING_READY lobby state
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     * @param {module:db.User} user - An inhouse user.
     */
    async onPlayerReady(lobbyState, user) {
        logger.silly(`ihlManager onPlayerReady ${lobbyState.id} ${user.id}`);
        await Lobby.setPlayerReady(true)(lobbyState)(user.id);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_CHECKING_READY]).catch(e => logger.error(e));
    }

    /**
     * Updates a lobby state with a captain pick selection
     * @async
     * @param {module:lobby.LobbyState} _lobbyState - An inhouse lobby state.
     * @param {module:db.User} captain - The captain user
     * @param {number} pick - The selected pick
     */
    async onSelectionPick(_lobbyState, captain, pick) {
        logger.silly(`ihlManager onSelectionPick ${_lobbyState.id} ${captain.id} ${pick}`);
        const lobby = await Lobby.getLobby(_lobbyState);
        const lobbyState = await Lobby.createLobbyState(_lobbyState.inhouseState)(_lobbyState)(_lobbyState);
        if (lobby.state === CONSTANTS.STATE_SELECTION_PRIORITY && Lobby.isCaptain(lobbyState)(captain)) {
            if (!lobbyState.playerFirstPick) {
                if (lobbyState[`captain${3 - lobbyState.selectionPriority}UserId`] === captain.id) {
                    lobbyState.playerFirstPick = pick === 1 ? 3 - lobbyState.selectionPriority : lobbyState.selectionPriority;
                    await Db.updateLobby(lobbyState);
                    this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_SELECTION_PRIORITY]).catch(e => logger.error(e));
                }
            }
            else if (!lobbyState.firstPick) {
                if (!lobbyState.radiantFaction && lobbyState[`captain${lobbyState.selectionPriority}UserId`] === captain.id) {
                    lobbyState.firstPick = pick === 1 ? lobbyState.selectionPriority : 3 - lobbyState.selectionPriority;
                    await Db.updateLobby(lobbyState);
                    this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_SELECTION_PRIORITY]).catch(e => logger.error(e));
                }
                else if (lobbyState.radiantFaction && lobbyState[`captain${3 - lobbyState.selectionPriority}UserId`] === captain.id) {
                    lobbyState.firstPick = pick === 1 ? 3 - lobbyState.selectionPriority : lobbyState.selectionPriority;
                    await Db.updateLobby(lobbyState);
                    this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_SELECTION_PRIORITY]).catch(e => logger.error(e));
                }
            }
        }
    }

    /**
     * Updates a lobby state with a captain side selection
     * @async
     * @param {module:lobby.LobbyState} _lobbyState - An inhouse lobby state.
     * @param {module:db.User} captain - The captain user
     * @param {number} side - The selected faction
     */
    async onSelectionSide(_lobbyState, captain, side) {
        logger.silly(`ihlManager onSelectionSide ${_lobbyState.id} ${captain.id} ${side}`);
        const lobby = await Lobby.getLobby(_lobbyState);
        const lobbyState = await Lobby.createLobbyState(_lobbyState.inhouseState)(_lobbyState)(_lobbyState);
        if (lobby.state === CONSTANTS.STATE_SELECTION_PRIORITY && Lobby.isCaptain(lobbyState)(captain)) {
            if (lobbyState.playerFirstPick) {
                if (!lobbyState.radiantFaction) {
                    if (!lobbyState.firstPick && lobbyState[`captain${lobbyState.selectionPriority}UserId`] === captain.id) {
                        lobbyState.radiantFaction = side === 1 ? lobbyState.selectionPriority : 3 - lobbyState.selectionPriority;
                        await Db.updateLobby(lobbyState);
                        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_SELECTION_PRIORITY]).catch(e => logger.error(e));
                    }
                    else if (lobbyState.firstPick && lobbyState[`captain${3 - lobbyState.selectionPriority}UserId`] === captain.id) {
                        lobbyState.radiantFaction = side === 1 ? 3 - lobbyState.selectionPriority : lobbyState.selectionPriority;
                        await Db.updateLobby(lobbyState);
                        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_SELECTION_PRIORITY]).catch(e => logger.error(e));
                    }
                }
            }
        }
    }

    /**
     * Checks if a player is draftable and fires an event representing the result.
     * If the player is draftable, checks for STATE_DRAFTING_PLAYERS lobby state and runs the lobby state.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     * @param {module:db.User} user - The picked user
     * @param {number} faction - The picking faction
     */
    async onDraftMember(_lobbyState, captain, user) {
        const lobby = await Lobby.getLobby(_lobbyState);
        const lobbyState = await Lobby.createLobbyState(_lobbyState.inhouseState)(_lobbyState)(_lobbyState);
        const faction = await Lobby.getDraftingFaction(lobbyState.inhouseState.draftOrder)(lobbyState);
        logger.silly(`ihlManager onDraftMember ${lobbyState.id} ${lobby.state} ${faction} ${captain.id} ${Lobby.isFactionCaptain(lobbyState)(faction)(captain)}`);
        if (lobby.state === CONSTANTS.STATE_DRAFTING_PLAYERS && Lobby.isFactionCaptain(lobbyState)(faction)(captain)) {
            const player = await Lobby.getPlayerByUserId(lobbyState)(user.id);
            if (player) {
                const result = await Lobby.isPlayerDraftable(lobbyState)(player);
                logger.silly(`ihlManager onDraftMember draftable ${result}`);
                if (result === CONSTANTS.PLAYER_DRAFTED) {
                    await Lobby.setPlayerFaction(faction)(lobbyState)(user.id);
                    this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_DRAFTING_PLAYERS]).catch(e => logger.error(e));
                }
                return result;
            }
            return CONSTANTS.INVALID_PLAYER_NOT_FOUND;
        }
        return null;
    }

    /**
     * Force lobby into player draft with assigned captains.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     * @param {module:db.User} captain1 - The captain 1 user
     * @param {module:db.User} captain2 - The captain 2 user
     */
    async onForceLobbyDraft(lobbyState, captain1, captain2) {
        logger.silly(`ihlManager onForceLobbyDraft ${captain1.id} ${captain2.id}`);
        if (lobbyState.botId) {
            await this.botLeaveLobby(lobbyState);
            await Lobby.unassignBotFromLobby(lobbyState);
        }
        await Lobby.forceLobbyDraft(lobbyState, captain1, captain2);
        await Db.updateLobby(lobbyState);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState).catch(e => logger.error(e));
    }

    /**
     * Start a dota lobby if all players are in the lobby and on the correct teams.
     * @param {module:lobby.lobbyState} _lobbyState - The lobby state to check.
     * @param {module:lobby.lobbyState} _dotaBot - The bot to check.
     * @returns {module:lobby.lobbyState}
     */
    async onStartDotaLobby(_lobbyState, _dotaBot) {
        const lobbyState = { ..._lobbyState };
        logger.silly(`ihlManager onStartDotaLobby ${lobbyState.id} ${lobbyState.botId} ${lobbyState.state}`);
        if (lobbyState.state !== CONSTANTS.STATE_WAITING_FOR_PLAYERS) return false;
        const dotaBot = _dotaBot || this.getBot(lobbyState.botId);
        if (dotaBot) {
            lobbyState.state = CONSTANTS.STATE_MATCH_IN_PROGRESS;
            lobbyState.startedAt = Date.now();
            lobbyState.matchId = await DotaBot.startDotaLobby(dotaBot);
            logger.silly(`ihlManager onStartDotaLobby matchId ${lobbyState.matchId} leagueid ${lobbyState.leagueid}`);
            if (lobbyState.leagueid) {
                await this.botLeaveLobby(lobbyState);
            }
            await Lobby.removeQueuers(lobbyState);
            await Db.updateLobby(lobbyState);
            this.matchTracker.addLobby(lobbyState);
            this.matchTracker.run();
            this[CONSTANTS.MSG_LOBBY_STARTED](lobbyState);
            logger.silly('ihlManager onStartDotaLobby true');
        }
        logger.silly('ihlManager onStartDotaLobby false');
        return lobbyState;
    }
    
    /**
     * Swap teams in the dota lobby.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     */
    async onLobbySwapTeams(lobbyState) {
        if (lobbyState.state !== CONSTANTS.STATE_WAITING_FOR_PLAYERS) return false;
        const dotaBot = this.getBot(lobbyState.botId);
        if (dotaBot) {
            await Db.updateLobbyRadiantFaction(lobbyState)(3 - lobbyState.radiantFaction);
            await dotaBot.flipLobbyTeams();
            return true;
        }
        return false;
    }

    /**
     * Kicks a player from the dota lobby.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     * @param {module:db.User} user - The player to kick
     */
    async onLobbyKick(lobbyState, user) {
        logger.silly(`ihlManager onLobbyKick ${lobbyState.id} ${user.id} ${lobbyState.botId} ${user.steamId64}`);
        const dotaBot = this.getBot(lobbyState.botId);
        if (dotaBot) {
            DotaBot.kickPlayer(dotaBot)(user);
        }
    }

    /**
     * Invites a player to the dota lobby.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     * @param {module:db.User} user - The player to invite
     */
    async onLobbyInvite(lobbyState, user) {
        logger.silly(`ihlManager onLobbyInvite ${lobbyState.id} ${user.id} ${lobbyState.botId} ${user.steamId64}`);
        const dotaBot = this.getBot(lobbyState.botId);
        if (dotaBot) {
            DotaBot.invitePlayer(dotaBot)(user);
        }
    }

    /**
     * Runs a lobby state when the lobby is ready (all players have joined and are in the right team slot).
     * Checks for STATE_WAITING_FOR_PLAYERS lobby state
     * @async
     * @param {string} dotaLobbyId - A dota lobby id.
     */
    async onLobbyReady(dotaLobbyId) {
        const lobby = await Db.findLobbyByDotaLobbyId(dotaLobbyId);
        logger.silly(`ihlManager onLobbyReady ${dotaLobbyId} ${lobby.id}`);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobby, [CONSTANTS.STATE_WAITING_FOR_PLAYERS]).catch(e => logger.error(e));
    }

    /**
     * Puts a lobby state in STATE_PENDING_KILL and runs lobby.
     * @async
     * @param {module:lobby.LobbyState} lobbyState - An inhouse lobby state.
     */
    async onLobbyKill(lobbyState) {
        logger.silly(`ihlManager onLobbyKill ${lobbyState.id}`);
        await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_PENDING_KILL);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_PENDING_KILL]).catch(e => logger.error(e));
    }

    /**
     * Handles match signed out bot event.
     * Updates STATE_MATCH_IN_PROGRESS lobby state to STATE_MATCH_ENDED
     * @async
     * @param {number} matchId - A dota match id.
     */
    async onMatchSignedOut(matchId) {
        const lobbyState = await Ihl.loadLobbyStateFromMatchId(this.client.guilds)(matchId.toString());
        logger.silly(`ihlManager onMatchSignedOut ${matchId} ${lobbyState.id} ${lobbyState.state}`);
        if (lobbyState.state === CONSTANTS.STATE_MATCH_IN_PROGRESS) {
            await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_MATCH_ENDED);
            logger.silly('ihlManager onMatchSignedOut state set to STATE_MATCH_ENDED');
            this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_MATCH_ENDED]).catch(e => logger.error(e));
        }
        await this.botLeaveLobby(lobbyState);
        await Lobby.unassignBotFromLobby(lobbyState);
    }

    /**
     * Handles match outcome bot event.
     * Updates lobby winner and player stats.
     * Sends match stats message.
     * Puts lobby into STATE_MATCH_STATS state
     * @async
     * @param {string} dotaLobbyId - A dota lobby id.
     * @param {external:Dota2.schema.EMatchOutcome} matchOutcome - The dota match outcome
     * @param {external:Dota2.schema.CDOTALobbyMember[]} members - Array of dota lobby members
     */
    async onMatchOutcome(dotaLobbyId, matchOutcome, members) {
        const lobbyState = await Ihl.loadLobbyStateFromDotaLobbyId(this.client.guilds)(dotaLobbyId);
        logger.silly(`ihlManager onMatchOutcome ${dotaLobbyId} ${matchOutcome} ${lobbyState.id}`);
        await setMatchPlayerDetails(matchOutcome)(members)(lobbyState);
        this[CONSTANTS.MSG_MATCH_STATS](lobbyState, lobbyState.inhouseState);
        await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_MATCH_STATS);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_MATCH_STATS]).catch(e => logger.error(e));
    }

    /**
     * Handles match tracker match stats event.
     * Sends match stats message.
     * Puts lobby into STATE_MATCH_STATS state
     * @async
     * @param {module:db.Lobby} lobby - A lobby database model
     */
    async onMatchStats(lobby) {
        logger.silly(`ihlManager onMatchStats ${lobby.id}`);
        const league = await Db.findLeagueById(lobby.leagueId);
        const inhouseState = await Ihl.createInhouseState({ league, guild: this.client.guilds.get(league.guildId) });
        const lobbyState = await Lobby.lobbyToLobbyState(inhouseState)(lobby);
        this[CONSTANTS.MSG_MATCH_STATS](lobbyState, inhouseState);
        await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_MATCH_STATS);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_MATCH_STATS]).catch(e => logger.error(e));
    }

    /**
     * Handles match tracker match no stats event.
     * Sends match no stats message.
     * Puts lobby into STATE_MATCH_NO_STATS state
     * @async
     * @param {module:db.Lobby} lobby - A lobby database model
     */
    async onMatchNoStats(lobby) {
        logger.silly(`ihlManager onMatchNoStats ${lobby.id}`);
        const league = await Db.findLeagueById(lobby.leagueId);
        const inhouseState = await Ihl.createInhouseState({ league, guild: this.client.guilds.get(league.guildId) });
        const lobbyState = await Lobby.lobbyToLobbyState(inhouseState)(lobby);
        this[CONSTANTS.MSG_MATCH_NO_STATS](lobbyState, inhouseState);
        await Db.updateLobbyState(lobbyState)(CONSTANTS.STATE_MATCH_NO_STATS);
        this[CONSTANTS.EVENT_RUN_LOBBY](lobbyState, [CONSTANTS.STATE_MATCH_NO_STATS]).catch(e => logger.error(e));
    }

    /**
     * IHLManager event queue empty event.
     *
     * @event module:ihlManager~empty
     */

    /**
     * Processes the inhouse manager event queue until it is empty.
     * Events are actions to perform (mostly on lobby states or something that resolves to a lobby state).
     * An event consists of a function, the arguments to call it with,
     * and the resolve and reject callbacks of the Promise wrapping the action.
     * When the action is executed, resolve with the returned value
     * or reject if an error was thrown.
     * The queue blocks while processing an action, so only 1 can be processed at a time.
     * @async
     * @fires module:ihlManager~empty
     */
    async processEventQueue() {
        if (this.blocking) return;
        if (this.eventQueue.length) {
            this.blocking = true;
            const [fn, args, resolve, reject] = this.eventQueue.shift();
            logger.silly(`ihlManager processEventQueue ${fn.name} ${this.eventQueue.length}`);
            try {
                const value = await fn.apply(this, args);
                resolve(value);
            }
            catch (e) {
                logger.error(e);
                reject(e);
            }
            this.blocking = false;
            if (this.eventQueue.length) {
                await this.processEventQueue();
            }
            else {
                this.emit('empty'); // test hook event
            }
        }
    }

    /**
     * Callback for a lobby processing event.
     *
     * @callback eventCallback
     */

    /**
     * Adds a lobby processing function and its arguments to the queue.
     * When the queue is processed the function will be executed with its arguments.
     * @async
     * @param {eventCallback} fn - A lobby processing event function.
     * @param {...*} args - A list of arguments the lobby processing event function will be called with.
     */
    async queueEvent(fn, args = []) {
        logger.silly(`ihlManager queueEvent ${fn.name} ${args}`);
        return new Promise((resolve, reject) => {
            this.eventQueue.push([fn, args, resolve, reject]);
            this.processEventQueue().catch(e => logger.error(e) && process.exit(1));
        });
    }

    /**
     * Gets a bot.
     * @param {number} botId - The bot id.
     * @returns {module:dotaBot.DotaBot}
     */
    getBot(botId) {
        logger.silly(`ihlManager getBot ${botId}`);
        return botId != null ? this.bots[botId] : null;
    }

    /**
     * Gets a bot by steam id.
     * @param {number} steamId64 - The bot steam id.
     * @returns {module:dotaBot.DotaBot}
     */
    getBotBySteamId(steamId64) {
        logger.silly(`ihlManager getBot ${botId}`);
        for (const dotaBot of Object.values(this.bots)) {
            if (dotaBot.steamId64 === steamId64) return dotaBot;
        }
        return null;
    }

    /**
     * Start a dota bot by id.
     * @async
     * @param {number} botId - The bot id.
     * @returns {module:dotaBot.DotaBot}
     */
    async loadBotById(botId) {
        logger.silly(`ihlManager loadBot ${botId}`);
        const bot = await Db.findBot(botId);
        return this.loadBot(bot);
    }

    /**
     * Start a dota bot by steam id.
     * @async
     * @param {number} steamId64 - The bot steam id.
     * @returns {module:dotaBot.DotaBot}
     */
    async loadBotBySteamId(steamId64) {
        logger.silly(`ihlManager loadBot ${steamId64}`);
        const bot = await Db.findBotBySteamId64(steamId64);
        return this.loadBot(bot);
    }

    /**
     * Start a dota bot.
     * @async
     * @param {module:db.Bot} bot - The bot model.
     * @returns {module:dotaBot.DotaBot}
     */
    async loadBot(bot) {
        logger.silly(`ihlManager loadBot ${bot.id}`);
        let dotaBot = this.getBot(bot.id);
        if (!dotaBot) {
            await Db.updateBotStatus(CONSTANTS.BOT_LOADING)(bot.id);
            try {
                dotaBot = await Fp.pipeP(
                    DotaBot.createDotaBot,
                    DotaBot.connectDotaBot,
                )(bot);
                dotaBot.on(CONSTANTS.MSG_CHAT_MESSAGE, (channel, senderName, message, chatData) => this[CONSTANTS.MSG_CHAT_MESSAGE](dotaBot.dotaLobbyId.toString(), channel, senderName, message, chatData));
                dotaBot.on(CONSTANTS.MSG_LOBBY_PLAYER_JOINED, member => this[CONSTANTS.MSG_LOBBY_PLAYER_JOINED](dotaBot.dotaLobbyId.toString(), member));
                dotaBot.on(CONSTANTS.MSG_LOBBY_PLAYER_LEFT, member => this[CONSTANTS.MSG_LOBBY_PLAYER_LEFT](dotaBot.dotaLobbyId.toString(), member));
                dotaBot.on(CONSTANTS.MSG_LOBBY_PLAYER_CHANGED_SLOT, state => this[CONSTANTS.MSG_LOBBY_PLAYER_CHANGED_SLOT](dotaBot.dotaLobbyId.toString(), state));
                dotaBot.on(CONSTANTS.EVENT_LOBBY_READY, () => this[CONSTANTS.EVENT_LOBBY_READY](dotaBot.dotaLobbyId.toString()).catch(e => logger.error(e)));
                dotaBot.on(CONSTANTS.EVENT_BOT_LOBBY_LEFT, () => this[CONSTANTS.EVENT_BOT_LOBBY_LEFT](bot.id).catch(e => logger.error(e)));
                dotaBot.on(CONSTANTS.EVENT_MATCH_SIGNEDOUT, matchId => this[CONSTANTS.EVENT_MATCH_SIGNEDOUT](matchId).catch(e => logger.error(e)));
                dotaBot.on(CONSTANTS.EVENT_MATCH_OUTCOME, (dotaLobbyId, matchOutcome, members) => this[CONSTANTS.EVENT_MATCH_OUTCOME](dotaLobbyId.toString(), matchOutcome, members).catch(e => logger.error(e)));
                this.bots[bot.id] = dotaBot;
                logger.silly('ihlManager loadBot loaded');
            }
            catch (e) {
                logger.error(e);
                await Db.updateBotStatus(CONSTANTS.BOT_FAILED)(bot.id);
                return null;
            }
        }
        logger.silly('ihlManager loadBot done');
        return dotaBot;
    }

    /**
     * Remove a dota bot.
     * @async
     * @param {number} botId - The bot id.
     */
    async removeBot(botId) {
        logger.silly(`ihlManager removeBot ${botId}`);
        const dotaBot = this.getBot(botId);
        if (dotaBot) {
            try {
                delete this.bots[botId];
                await DotaBot.disconnectDotaBot(dotaBot);
                logger.silly('ihlManager removeBot removed');
            }
            catch (e) {
                logger.error(e);
                await Db.updateBotStatus(CONSTANTS.BOT_FAILED)(botId);
            }
        }
    }

    /**
     * Disconnect a dota bot from its lobby.
     * The bot should eventually emit EVENT_BOT_LOBBY_LEFT.
     * @param {module:lobby.lobbyState} lobbyState - The lobby for the bot.
     * @returns {null|string} Null if the bot left the lobby or a string containing the error reason.
     */
    async botLeaveLobby(lobbyState) {
        logger.silly(`ihlManager botLeaveLobby ${lobbyState.id}`);
        const dotaBot = this.getBot(lobbyState.botId);
        if (dotaBot) {
            if (equalsLong(lobbyState.dotaLobbyId, dotaBot.dotaLobbyId)) {
                await dotaBot.leavePracticeLobby();
                await dotaBot.abandonCurrentGame();
                await dotaBot.leaveLobbyChat();
                logger.silly(`ihlManager botLeaveLobby bot ${lobbyState.botId} left lobby ${lobbyState.dotaLobbyId}`);
                return null;
            }
            return `Lobby ID mismatch. Expected: ${lobbyState.dotaLobbyId}. Actual: ${dotaBot.dotaLobbyId}.`;
        }
        return `Bot ID: ${lobbyState.botId} not found.`;
    }

    /**
     * Bind all events to their corresponding event handler functions
     */
    attachListeners() {
        for (const eventName of Object.keys(CONSTANTS)) {
            if (eventName.startsWith('EVENT_') || eventName.startsWith('MSG_')) {
                this.on(CONSTANTS[eventName], this[eventName].bind(this));
            }
        }
    }
}
Object.assign(IHLManager.prototype, LobbyStateHandlers.LobbyStateHandlers({ DotaBot, Db, Guild, Lobby, MatchTracker }));
Object.assign(IHLManager.prototype, LobbyQueueHandlers({ Db, Lobby }));
Object.assign(IHLManager.prototype, EventListeners({ Db }));
Object.assign(IHLManager.prototype, MessageListeners({ Db, Guild, Lobby, MatchTracker, Ihl }));

module.exports = {
    findUser,
    loadInhouseStates,
    loadInhouseStatesFromLeagues,
    createClient,
    setMatchPlayerDetails,
    IHLManager,
};