Source: app/channel/connectedUser.js

/*Canopy - The next generation of stoner streaming software
Copyright (C) 2024-2025 Rainbownapkin and the TTN Community

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program.  If not, see <https://www.gnu.org/licenses/>.*/

//local imports
const config = require('../../../config.json');
const channelModel = require('../../schemas/channel/channelSchema');
const permissionModel = require('../../schemas/permissionSchema');
const flairModel = require('../../schemas/flairSchema');
const emoteModel = require('../../schemas/emoteSchema');
const { userModel } = require('../../schemas/user/userSchema');

/**
 * Class representing a single user connected to a channel
 */
class connectedUser{
    /**
     * Instantiates a connectedUser object
     * @param {Mongoose.Document} userDB - User document to re-hydrate user from
     * @param {PemissionModel.chanRank} chanRank - Enum representing user channel rank
     * @param {String} - Channel the user is connecting to
     * @param {Socket} socket - Socket associated with the users connection
     */
    constructor(userDB, chanRank, channel, socket){
        this.id = userDB.id;
        this.user = userDB.user;
        this.rank = userDB.rank;
        this.highLevel = userDB.highLevel;

        //Check to make sure users flair entry from DB is good 
        if(userDB.flair != null){
            //Use flair from DB
            this.flair = userDB.flair.name;
        //Otherwise
        }else{
            //Gracefully default to classic
            this.flair = 'classic';
        }

        this.chanRank = chanRank;
        this.channel = channel;
        this.sockets = [socket.id];
    }

    /**
     * Handles server-side initialization for new connections from a specific user
     * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
     * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access
     * @param {Socket} socket - Requesting Socket
     */
    async handleConnection(userDB, chanDB, socket){
        //send metadata to client
        this.sendClientMetadata();

        //Send out emotes
        this.sendSiteEmotes();
        this.sendChanEmotes(chanDB);
        this.sendPersonalEmotes(userDB);

        //Send out used tokes
        this.sendUsedTokes(userDB);

        //Send out the currently playing item
        this.channel.queue.sendMedia(socket);
        
        //If we're proxied
        if(config.proxied){
            //Tattoo hashed IP address from reverse proxy to user account for seven days
            await userDB.tattooIPRecord(socket.handshake.headers['x-forwarded-for']);
        }else{
            //Tattoo hashed IP address to user account for seven days
            await userDB.tattooIPRecord(socket.handshake.address);
        }
    }

    /**
     * Iterates through all known connections for a given user, running them through a supplied callback function
     * @param {Function} cb - Callback to call against found sockets for a given user
     */
    socketCrawl(cb){
        //Crawl through user's sockets (lol)
        this.sockets.forEach((sockid) => {
            //get socket based on ID
            const socket = this.channel.server.io.sockets.sockets.get(sockid);
            //Callback with socket
            cb(socket);
        });
    }


    /**
     * Emits an event to all known sockets for a given user
     * 
     * My brain keeps going back to using dynamic per-user namespaces for this
     * but everytime i look into it I come to the conclusion that it's a bad idea, then I toy with making chans namespaces
     * and using per-user channels for this, but what of gold or mod-only features? or games?
     * No matter what it'd probably end up hacky, as namespaces where meant for splitting app logic not user comms (like rooms).
     * at the end of the day there has to be some penance for decent multi-session handling on-top of a library that doesn't do it.
     * Having to crawl through these sockets is that. Because the other ways seem more gross somehow.
     * @param {String} eventName - Event name to emit to client sockets
     * @param {Object} data - Data to emit to client sockets
     */
    emit(eventName, data){
        this.socketCrawl((socket)=>{
            //Ensure our socket is initialized
            if(socket != null){
                socket.emit(eventName, data);
            }
        });
    }

    /**
     * Disconnects all sockets for a given user
     * @param {String} reason - Reason for being disconnected
     * @param {String} type - Disconnection Type
     */
    disconnect(reason, type = "Disconnected"){
        this.emit("kick",{type, reason});
        this.socketCrawl((socket)=>{socket.disconnect()});
    }

    //This is the big first push upon connection
    //It should only fire once, so things that only need to be sent once can be slapped into here
    /**
     * Sends glut of required initial metadata to the client upon a new connection
     * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
     * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access 
     */
    async sendClientMetadata(userDB, chanDB){
        //Get flairList from DB and setup flairList array
        const flairListDB = await flairModel.find({});
        var flairList = [];

        //if we wherent handed a user document
        if(userDB == null){
            //Pull it based on user name
            userDB = await userModel.findOne({user: this.user});
        }

        //if we wherent handed a channel document
        if(chanDB == null){
            //Pull it based on channel name
            chanDB = await channelModel.findOne({name: this.channel.name});
        }

        //If our perm map is un-initiated 
        //can't set this in constructor easily since it's asyncornous
        //need to wait for it to complete before sending this off, but shouldnt re-do the wait for later connections
        if(this.permMap == null){
            //Grab perm map
            this.permMap = await chanDB.getPermMapByUserDoc(userDB);
        }

        //Setup our userObj
        const userObj = {
            id: this.id,
            user: this.user,
            rank: this.rank,
            chanRank: this.chanRank,
            highLevel: this.highLevel,
            permMap: {
                site: Array.from(this.permMap.site),
                chan: Array.from(this.permMap.chan),
            },
            flair: this.flair,
        }

        //For each flair listed in the Database
        flairListDB.forEach((flair)=>{
            //Check if the user has permission to use the current flair
            if(permissionModel.rankToNum(flair.rank) <= permissionModel.rankToNum(this.rank)){
                //If so push a light version of the flair object into our final flair list
                flairList.push({
                    name: flair.name,
                    displayName: flair.displayName
                });
            }
        });

        //Get schedule as a temporary array
        const queue = await this.channel.queue.prepQueue(chanDB);

        //Get schedule lock status
        const queueLock = this.channel.queue.locked;

        //Get chat buffer
        const chatBuffer = this.channel.chatBuffer.buffer;

        //Send off the metadata to our user's clients
        this.emit("clientMetadata", {user: userObj, flairList, queue, queueLock, chatBuffer});
    }

    /**
     * Send copy of site emotes to the user
     */
    async sendSiteEmotes(){
        //Get emote list from DB
        const emoteList = await emoteModel.getEmotes();

        //Send it off to the user
        this.emit('siteEmotes', emoteList);
    }

    /**
     * Send copy of channel emotes to the user
     * @param {Mongoose.Document} chanDB - Channnel Document Passthrough to save on DB Access 
     */
    async sendChanEmotes(chanDB){
        //if we wherent handed a channel document
        if(chanDB == null){
            //Pull it based on channel name
            chanDB = await channelModel.findOne({name: this.channel.name});
        }

        //Pull emotes from channel
        const emoteList = chanDB.getEmotes();

        //Send it off to the user
        this.emit('chanEmotes', emoteList);
    }

    /**
     * Send copy of channel emotes to the user
     * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
     */
    async sendPersonalEmotes(userDB){
        //if we wherent handed a user document
        if(userDB == null){
            //Pull it based on user name
            userDB = await userModel.findOne({user: this.user});
        }

        //Pull emotes from channel
        const emoteList = userDB.getEmotes();

        //Send it off to the user
        this.emit('personalEmotes', emoteList);
    }

    /**
     * Send copy of channel emotes to the user
     * @param {Mongoose.Document} userDB - User Document Passthrough to save on DB Access
     */
    async sendUsedTokes(userDB){
        //if we wherent handed a user document
        if(userDB == null){
            //Pull it based on user name
            userDB = await userModel.findOne({user: this.user});
        }

        //Create array of used toks from toke map and send it out to the user
        this.emit('usedTokes',{
            tokes: Array.from(userDB.tokes.keys())
        });
    }

    /**
     * Set flair for a given user and broadcast update to clients
     * @param {String} flair - Flair string to update user's flair to
     */
    updateFlair(flair){
        this.flair = flair;

        this.channel.broadcastUserList();
        this.sendClientMetadata();
    }

    /**
     * Set high level for a given user and broadcast update to clients
     * @param {Number} highLevel - Number to update user's high-level to
     */
    updateHighLevel(highLevel){
        this.highLevel = highLevel;

        //TODO: show high-level in userlist
        this.channel.broadcastUserList();
        this.sendClientMetadata();
    }
}

module.exports = connectedUser;