Source: app/channel/commandPreprocessor.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/>.*/

//NPM Imports
const validator = require('validator');//No express here, so regular validator it is!

//Local Imports
const tokebot = require('./tokebot');
const linkUtils = require('../../utils/linkUtils');
const permissionModel = require('../../schemas/permissionSchema');
const channelModel = require('../../schemas/channel/channelSchema');

/**
 * Class containing global server-side chat/command pre-processing logic
 */
module.exports = class commandPreprocessor{
    /**
     * Instantiates a commandPreprocessor object
     * @param {channelManager} server - Parent Server Object
     * @param {chatHandler} chatHandler - Parent Chat Handler Object
     */
    constructor(server, chatHandler){
        this.server = server;
        this.chatHandler = chatHandler;
        this.commandProcessor = new commandProcessor(server, chatHandler);
        this.tokebot = new tokebot(server, chatHandler);
    }

    /**
     * Ingests a command/chat request from Chat Handler and pre-processes and processes it accordingly
     * @param {Socket} socket - Socket we're receiving the request from
     * @param {Object} data - Event payload
     */
    async preprocess(socket, data){
        //Set command object
        const commandObj = {
            socket,
            sendFlag: true,
            rawData: data,
            chatType: 'chat'
        }

        //If we don't pass sanatization/validation turn this car around
        if(!this.sanatizeCommand(commandObj)){
            return;
        }

        //split the command
        this.splitCommand(commandObj);

        //Process the command
        await this.processServerCommand(commandObj);

        //If we're going to relay this command as a message, continue on to chat processing
        if(commandObj.sendFlag){
            //Prep the message
            await this.prepMessage(commandObj);

            //Send the chat
            this.sendChat(commandObj);
        }
    }

    /**
     * Sanatizes and Validates a single user chat message/command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} false if Command/Message is too long to send
     */
    sanatizeCommand(commandObj){
        //Trim and Sanatize for XSS
        commandObj.command = validator.trim(validator.escape(commandObj.rawData.msg));

        //Return whether or not the shit was long enough
        return (validator.isLength(commandObj.rawData.msg, {min: 1, max: 255}));
    }

    /**
     * Splits raw chat/command data into seperate arrays, one by word-borders and words surrounded by word-borders
     * These arrays are used to handle further command/chat processing
     * @param {Object} commandObj - Object representing a single given command/chat request
     */
    splitCommand(commandObj){
        //Split string by words
        commandObj.commandArray = commandObj.command.split(/\b/g);//Split by word-borders
        commandObj.argumentArray = commandObj.command.match(/\b\w+\b/g);//Match by words surrounded by borders
    }

    /**
     * Uses the server's Command Processor object to process the chat/command request.
     * @param {Object} commandObj - Object representing a single given command/chat request
     */
    async processServerCommand(commandObj){
        //If the raw message starts with '!' (skip commands that start with whitespace so people can send example commands in chat)
        if(commandObj.rawData.msg[0] == '!'){
            //if it isn't just an exclimation point, and we have a real command
            if(commandObj.argumentArray != null){
                //If the command processor knows what to do with whatever the fuck the user sent us
                if(this.commandProcessor[commandObj.argumentArray[0].toLowerCase()] != null){
                    //Process the command and use the return value to set the sendflag (true if command valid)
                    commandObj.sendFlag = await this.commandProcessor[commandObj.argumentArray[0].toLowerCase()](commandObj, this);
                }else{
                    //Process as toke command if we didnt get a match from the standard server-side command processor
                    commandObj.sendFlag = await this.tokebot.tokeProcessor(commandObj);
                }
            }
        }
    }

    /**
     * Iterates through links in message and marks them by link type for later use by client-side post-processing
     * @param {Object} commandObj - Object representing a single given command/chat request
     */
    async markLinks(commandObj){
        //Setup the links array
        commandObj.links = [];

        //For each link sent from the client
        //this.rawData.links.forEach((link) => {
        for (const link of commandObj.rawData.links){
            //Add a marked link object to our links array
            commandObj.links.push(await linkUtils.markLink(link));
        }
    }

    /**
     * Re-creates message string from processed Command Array
     * @param {Object} commandObj - Object representing a single given command/chat request
     */
    async prepMessage(commandObj){
        //Create message from commandArray
        commandObj.message = commandObj.commandArray.join('').trimStart();
        //Validate links and mark them by embed type
        await this.markLinks(commandObj);
    }

    /**
     * Relays chat to channel via parent Chat Handler object
     * @param {Object} commandObj - Object representing a single given command/chat request
     */
    sendChat(commandObj){
        //FUCKIN' SEND IT!
        this.chatHandler.relayUserChat(commandObj.socket, commandObj.message, commandObj.chatType, commandObj.links);
    }
}

/**
 * Class representing global server-side chat/command processing logic
 */
class commandProcessor{
    /**
     * Instantiates a commandProcessor object
     * @param {channelManager} server - Parent Server Object
     * @param {chatHandler} chatHandler - Parent Chat Handler Object
     */
    constructor(server, chatHandler){
        this.server = server;
        this.chatHandler = chatHandler;
    }

    //Command keywords get run through .toLowerCase(), so we should use lowercase method names for command methods
    /**
     * Command Processor method to handle the '!whisper' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag
     */
    whisper(commandObj){
        //splice out our command
        commandObj.commandArray.splice(0,2);

        //Mark out the current message as a whisper
        commandObj.chatType = 'whisper';

        //Make sure to throw the send flag
        return true
    }

    /**
     * Command Processor method to handle the '!spoiler' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag
     */
    spoiler(commandObj){
        //splice out our command
        commandObj.commandArray.splice(0,2);

        //Mark out the current message as a spoiler
        commandObj.chatType = 'spoiler';

        //Make sure to throw the send flag
        return true
    }

    /**
     * Command Processor method to handle the '!strikethrough' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag
     */
    strikethrough(commandObj){
        //splice out our command
        commandObj.commandArray.splice(0,2);

        //Mark out the current message as a spoiler
        commandObj.chatType = 'strikethrough';

        //Make sure to throw the send flag
        return true
    }

    /**
     * Command Processor method to handle the '!bold' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag
     */
    bold(commandObj){
        //splice out our command
        commandObj.commandArray.splice(0,2);

        //Mark out the current message as a spoiler
        commandObj.chatType = 'bold';

        //Make sure to throw the send flag
        return true
    }

    /**
     * Command Processor method to handle the '!italics' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag
     */
    italics(commandObj){
        //splice out our command
        commandObj.commandArray.splice(0,2);

        //Mark out the current message as a spoiler
        commandObj.chatType = 'italics';

        //Make sure to throw the send flag
        return true
    }

    /**
     * Command Processor method to handle the '!announce' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag on un-authorized call to shame the user
     */
    async announce(commandObj, preprocessor){
        //Get the current channel from the database
        const chanDB = await channelModel.findOne({name: commandObj.socket.chan});

        //Check if the user has permission, and publicly shame them if they don't (lmao)
        if(chanDB != null && await chanDB.permCheck(commandObj.socket.user, 'announce')){
            //splice out our command
            commandObj.commandArray.splice(0,2);

            //Prep the message using pre-processor functions chat-handling
            await preprocessor.prepMessage(commandObj);

            //send it
            this.chatHandler.relayChannelAnnouncement(commandObj.socket.chan, commandObj.message, commandObj.links);

            //throw send flag
            return false;
        }

        //throw send flag
        return true;
    }

    /**
     * Command Processor method to handle the '!serverannounce' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag on un-authorized call to shame the user
     */
    async serverannounce(commandObj, preprocessor){
        //Check if the user has permission, and publicly shame them if they don't (lmao)
        if(await permissionModel.permCheck(commandObj.socket.user, 'announce')){
            //splice out our command
            commandObj.commandArray.splice(0,2);

            //Prep the message using pre-processor functions for chat-handling
            await preprocessor.prepMessage(commandObj);

            //send it
            this.chatHandler.relayServerAnnouncement(commandObj.message, commandObj.links);

            //disble send flag
            return false;
        }

        //throw send flag
        return true;
    }

    /**
     * Command Processor method to handle the '!resettoke' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag on un-authorized call to shame the user
     */
    async resettoke(commandObj, preprocessor){
        //Check if the user has permission, and publicly shame them if they don't (lmao)
        if(await permissionModel.permCheck(commandObj.socket.user, 'resetToke')){
            //Acknowledge command
            this.chatHandler.relayTokeWhisper(commandObj.socket, 'Toke cooldown reset.');

            //Tell tokebot to reset the toke
            preprocessor.tokebot.resetToke();

            //disable send flag
            return false;
        }

        //throw send flag
        return true;
    }

    /**
     * Command Processor method to handle the '!clear' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag on un-authorized call to shame the user
     */
    async clear(commandObj){
        //Get the current channel from the database
        const chanDB = await channelModel.findOne({name: commandObj.socket.chan});

        //Check if the user has permission, and publicly shame them if they don't (lmao)
        if(await chanDB.permCheck(commandObj.socket.user, 'clearChat')){
            //Send off the command
            this.chatHandler.clearChat(commandObj.socket.chan, commandObj.argumentArray[1]);
            //disable send flag
            return false;
        }

        //throw send flag
        return true;
    }

    /**
     * Command Processor method to handle the '!kick' command
     * @param {Object} commandObj - Object representing a single given command/chat request
     * @returns {Boolean} True to enable send flag on un-authorized call to shame the user
     */
    async kick(commandObj){
        //Get the current channel from the database
        const chanDB = await channelModel.findOne({name: commandObj.socket.chan});

        //Check if the user has permission, and publicly shame them if they don't (lmao)
        if(await chanDB.permCheck(commandObj.socket.user, 'kickUser')){
            //Get username from argument array
            const username = commandObj.argumentArray[1];

            //Get channel
            const channel = this.server.activeChannels.get(commandObj.socket.chan);

            //get initiator and target user objects
            const initiator = channel.userList.get(commandObj.socket.user.user);
            const target = channel.userList.get(username);

            //get initiator and target override abilities
            const override = await permissionModel.overrideCheck(commandObj.socket.user, 'kickUser');
            const targetOverride = await permissionModel.overrideCheck(target, 'kickUser');

            //If there is no user
            if(target == null){
                //silently drop the command
                return false;
            }


            //If the user is capable of overriding this permission based on site permissions
            if(override || targetOverride){
                //If the site rank is equal
                if(permissionModel.rankToNum(initiator.rank) == permissionModel.rankToNum(target.rank)){
                    //compare chan rank
                    if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
                        //shame the person running it
                        return true;
                    }
                //otherwise
                }else{
                    //compare site rank
                    if(permissionModel.rankToNum(initiator.rank) <= permissionModel.rankToNum(target.rank)){
                        //shame the person running it
                        return true;
                    }
                }
            }else{
                //If the target has a higher chan rank than the initiator
                if(permissionModel.rankToNum(initiator.chanRank) <= permissionModel.rankToNum(target.chanRank)){
                    //shame the person running it
                    return true;
                }
            }


            //Splice out kick
            commandObj.commandArray.splice(0,4)

            //Get collect reason
            var reason = commandObj.commandArray.join('');

            //If no reason was given
            if(reason == ''){
                //Fill in a generic reason
                reason = "You have been kicked from the channel!";
            }

            //Kick the user
            target.disconnect(reason);

            //throw send flag
            return false;
        }

        //throw send flag
        return true;
    }
}