Character Selector

Summon any character in a normal chat

// ==UserScript==
// @name         Character Selector
// @description  Summon  any character in a normal chat
// @version      1.0.4
// @match        https://beta.character.ai/chat2*
// @match        https://plus.character.ai/chat2*
// @match        https://old.character.ai/chat2*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=character.ai
// @grant        none
// @namespace https://greasyfork.org/users/1077492
// @run-at      document-start
// ==/UserScript==
(function() {
    var func = window.WebSocket.prototype.send;
    var neo_socket = null;
    var initialized = false;
    var charDataCache = [];
    var chatCharDataCache = [];
    var selectedCharExternalId = "";
    var dropdown = null;
    var modal = null;
    var watchdog = null;

    class ProfilePhotoWatchdog {
        constructor() {
            this.dom = null;
            this.observer = null;
            this.initialized = false;
            this.firstcheck = false;
            this.tryInit();

            setTimeout(this.tryInit, 3000);
        }

        tryInit() {
            try {
                var self = this;
                this.dom = document.querySelector(".chat2");
                if (this.initialized || this.dom == undefined || this.dom == null) {
                    setTimeout(this.tryInit, 3000);
                    return;
                }

                let thisElement = this.dom.childNodes[1];

                if (thisElement.className.indexOf("react-scroll") == -1) {
                    setTimeout(this.tryInit, 3000);
                    return;
                }

                thisElement = thisElement.childNodes[0];
                this.dom = thisElement;

                this.observer = new MutationObserver(function (e) {
                    e.forEach(function(record) {
                        if (record.addedNodes.length > 0) {
                            for (let i = 0; i < record.addedNodes.length; i++) {
                                let item = record.addedNodes[i];
                                console.log(item);
                                watchdog.analyzeNode(item);
                            }
                        }
                    });
                });

                this.observer.observe(thisElement, { childList: true });
                this.initialized = true;
                this.firstTreatment();
            } catch (ex) {
                setTimeout(this.tryInit, 3000);
            }
        }

        firstTreatment() {
            if (!this.firstcheck) {
                let nodes = this.dom.childNodes;
                for (let i = 0; i < nodes.addedNodes.length; i++) {
                    let node = nodes[i];
                    this.analyzeNode(node);
                }
                this.firstcheck = true;
            }
        }

        analyzeNode(node) {
            let img = node.querySelector("img");
            let p = node.querySelector("p");

            if (img !== null && p !== null) {
                //so maybe this is a message, idk
                try {
                    let element = node.querySelector(".rounded");

                    if (element !== null) { //hmm
                        //lazy method to find out who the message is from
                        //console.log("element", element, "elementParent", element.parentElement);

                        chatCharDataCache.forEach(function(charData) {
                            if (element.parentElement.innerHTML.indexOf(charData.participant__name) != -1) {
                                img.src = "https://characterai.io/i/80/static/avatars/" + charData.avatar_file_name;
                            }
                        });
                    }
                } catch (ex) {
                    //nope, it was not.
                }
            }
        }
    }

    class FakeDropdownController {
        constructor() {
            this.dom = document.createElement("div");
            this.dom.innerHTML = '<div class="col-auto ps-2 dropdown dropup show"><span data-toggle="dropdown" aria-haspopup="listbox" class="" aria-expanded="true"> <div data-tag="currentChar" style="cursor: pointer;display: flex;justify-content: center;align-items: center;"><b data-tag="currentCharName">Switch</b><svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"> <path fill="none" d="M0 0h24v24H0z"></path> <path d="M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z"></path> </svg></div> </span> <div data-tag="dropDownMenu" tabindex="-1" role="listbox" aria-hidden="false" class="dropdown-menu show" style="position: absolute;inset: auto 0px 0px auto;transform: translate(0px, -24px);display: none;"> <h6 tabindex="-1" class="dropdown-header">Select character...</h6> <div tabindex="-1" class="dropdown-divider"></div><button type="button" data-tag="charBtn" tabindex="0" role="option" class="dropdown-item" style="display: flex; align-items: center;"> <div>Char</div> </button><button type="button" data-tag="newCharBtn" tabindex="0" role="option" class="dropdown-item" style="display: flex; align-items: center;"> <div><em>new character...</em></div> </button> </div> </div>';
            this.dom.style = "display: flex; justify-content: center; align-items: center; margin: 10px; user-select:none";
            this.charbtn = this.dom.querySelector("[data-tag=charBtn]");
            this.newcharbtn = this.dom.querySelector("[data-tag=newCharBtn]");
            this.dropdownmenu = this.dom.querySelector("[data-tag=dropDownMenu]");
            this.currentchar = this.dom.querySelector("[data-tag=currentChar]");
            this.isvisible = false;

            this.dom.addEventListener("click", this.onClick.bind(this));
            this.newcharbtn.addEventListener("click", this.onAddNewChar.bind(this));
            document.getElementById("user-input").parentElement.insertBefore(this.dom, null);
        }

        onClick(e) {
            var self = this;
            this.isvisible = !this.isvisible;
            this.dropdownmenu.style.display = this.isvisible ? "block" : "none";

            let buttons = this.dropdownmenu.querySelectorAll("button");

            for(var i = 0; i < buttons.length; i++) {
                let button = buttons[i];
                if (button.getAttribute("data-tag") === "newCharBtn") {
                    continue;
                }

               button.parentNode.removeChild(button);
            }

            chatCharDataCache.forEach(function(charData) {
                let newUiElement = self.charbtn.cloneNode(true);
                newUiElement.innerText = charData.participant__name;
                newUiElement.setAttribute("data-externalid", charData.external_id);
                newUiElement.addEventListener("click", function(e) {
                    selectCharacter(this.getAttribute("data-externalid"));
                });

                self.dropdownmenu.querySelector(".dropdown-divider").parentElement.insertBefore(newUiElement, self.newcharbtn);
            });
        }

        onAddNewChar(e) {
            modal = new FakeModalController();
        }
    }

    class FakeModalController {
        constructor() {
            this.dom = document.createElement("div");
            this.dom.innerHTML = '<div class=""> <div class="modal fade show" role="dialog" tabindex="-1" style="display: block;"> <div class="modal-dialog modal-dialog-centered" role="document" style="margin-top: 0px;"> <div class="modal-content"> <div class="modal-body"> <div data-tag="charList" style="user-select:none;max-height: 300px;overflow-y: scroll;overflow-x: hidden;display: flex;flex-direction: column;"> <div data-tag="charOption" style="width:100%"> <img src="https://characterai.io/i/80/static/avatars/uploaded/2023/3/22/WOUx3xnZRql_j1TsQfS1TcNCI30D6uoPQvlGlKdYxHg.webp" style="height: 45px;width: 45px;margin-right: 10px;border-radius: 45px;object-fit: contain;"><b style="pointer-events:none">charname</b> <span style="pointer-events:none">@charowner</span> </div> </div> <input data-tag="searchInput" placeholder="Search..." style="width: 100%;"> </div> <div class="modal-footer"><button data-tag="cancelButton" type="button" class="btn btn-secondary">Cancel</button><button data-tag="addButton" type="button" disabled="" class="btn btn-primary disabled">Add</button></div> </div> </div> </div> <div class="modal-backdrop fade show"></div> </div>';
            this.dom.style = "position: relative; z-index: 1050; display: block;";
            this.chartemplate = this.dom.querySelector('[data-tag="charOption"]');
            this.charlist = this.dom.querySelector('[data-tag="charList"]');
            this.addbtn = this.dom.querySelector('[data-tag="addButton"]');
            this.charlist.removeChild(this.chartemplate);
            this.selectedid = "";
            this.selected = null;

            this.dom.querySelector('[data-tag="cancelButton"]').addEventListener("click", this.onCancel.bind(this));
            this.addbtn.addEventListener("click", this.onAdd.bind(this));
            this.dom.querySelector('[data-tag="searchInput"]').addEventListener("keyup", this.onSearchInputKey.bind(this));

            document.body.appendChild(this.dom);
            this.onData(charDataCache.slice(0, 100));
        }

        onCancel(e) {
            this.dom.parentNode.removeChild(this.dom);
        }

        onSearchInputKey(e) {
            let value = e.target.value.toLowerCase();

            let results = charDataCache.filter(function (charData) {
                return charData.participant__name.toLowerCase().indexOf(value) != -1;
            });

            this.onData(results.slice(0, 100));
        }

        onData(data) {
            var self = this;
            this.charlist.innerHTML = "";
            data.forEach(function(each) {

                let newUiElement = self.chartemplate.cloneNode(true);
                newUiElement.querySelector("b").innerText = each.participant__name;
                newUiElement.querySelector("span").innerText = "@" + each.user__username;
                newUiElement.setAttribute("data-externalid", each.external_id);
                newUiElement.querySelector("img").src = "https://characterai.io/i/80/static/avatars/" + each.avatar_file_name;

                //No css injected, so i need use this lol
                newUiElement.addEventListener("click", function(e) {
                    if (self.selected !== null) {
                        self.selected.style.backgroundColor = "";
                    }

                    self.selected = e.target;
                    self.selectedid = e.target.getAttribute("data-externalid");
                    e.target.style.backgroundColor = "rgb(68 114 175 / 58%)";

                    self.addbtn.classList.remove("disabled");
                    self.addbtn.removeAttribute("disabled");
                });

                self.charlist.appendChild(newUiElement);
            });
        }

        onAdd(e) {
            this.dom.parentNode.removeChild(this.dom);
            selectCharacter(this.selectedid);
        }
    }

    function tryGetCurrentCharacter() {
        let external_id = new URLSearchParams(document.location.search).get("char");
        if (external_id != null) {
            getCharacterInfo(external_id, function() {
                selectCharacter(external_id);
            });
        }
    }

    async function getCharacterInfo(external_id, callback) {
        let token = localStorage.getItem("char_token");
        let response = await fetch("https://" + document.location.hostname + "/chat/character/info/", {
            mode: "cors",
            cache: "no-cache",
            credentials: "include",
            headers: {
                "Content-Type": "application/json",
                "Authorization": "Token " + JSON.parse(token).value,
            },
            method: "POST",
            body : JSON.stringify({ "external_id" : external_id })
        });

        if (response.ok) {
            let json = await response.json();

            if (!charDataCache.some(function(charData) {
                return charData.external_id === external_id;
            })) {
                charDataCache.push(json.character);
            }

            if (callback) {
                callback();
            }
        } else {
            console.log("not ok");
        }
    }

    function selectCharacter(external_id) {
        let results = charDataCache.filter(function (charData) {
            return charData.external_id == external_id;
        });

        if (results.length != 0) {
            let result = results[0];

            if (!chatCharDataCache.some(function(charData) {
                return charData.external_id === external_id;
            })) {
                chatCharDataCache.push(result);
            }

            selectedCharExternalId = external_id;
            dropdown.currentchar.querySelector('[data-tag="currentCharName"]').innerText = result.participant__name;
        } else {
            selectedCharExternalId = "";
            alert("Error: No character data for: " + external_id);
        }
    }

    async function fetchInitialData() {

        let token = localStorage.getItem("char_token");
        let response = null;

        if (token !== null) {
            response = await fetch("https://" + document.location.hostname + "/chat/characters/recent/", {
                mode: "cors",
                cache: "no-cache",
                credentials: "include",
                headers: {
                    "Authorization": "Token " + JSON.parse(token).value,
                }
            });

            if (response.ok) {
                let json = await response.json();
                charDataCache = charDataCache.concat(json.characters);
            } else {
                alert("Error fetching recent character data...");
            }
        }

        response = await fetch("https://" + document.location.hostname + "/chat/characters/public/", {
            mode: "cors",
            cache: "no-cache",
            credentials: "include",
            headers: {
                "Content-Type": "application/json",
            }
        });

        if (response.ok) {
            let json = await response.json();
            charDataCache = charDataCache.concat(json.characters);
        } else {
            alert("Error fetching character data...");
        }

        tryGetCurrentCharacter();
    }

    function addToPane() {
        dropdown = new FakeDropdownController();
        watchdog = new ProfilePhotoWatchdog();
    }

    window.addEventListener("load", function() {

        fetchInitialData();

        var checkInit = function() {
            if (document.querySelector(".chat2") === null) {
                return false;
            }

            addToPane();

            var x = new MutationObserver(function (e) {
                e.forEach(function(record) {
                    if (record.addedNodes.length > 0) {
                        for (let i = 0; i < record.addedNodes.length; i++) {
                            let item = record.addedNodes[i];
                            if (item.className == "container-fluid chatbottom") {
                                addToPane();
                            }
                        }
                    }
                });
            });

            x.observe(document.querySelector(".chat2"), { childList: true });
            return true;
        }

        var infinitecheck = function() {
            if (!checkInit()) {
                setTimeout(infinitecheck, 10);
            }
        }

        infinitecheck();
    }, { once: true });

    function onMessage(message) {
        var json = JSON.parse(message.data);

        if (json.hasOwnProperty("command")) {
            switch(json.command) {
                case "add_turn": {

                    break;
                }
            }
        }
    }

    window.WebSocket.prototype.send = function(...args) {
        try {
            var json = JSON.parse(args[0]);

            if (json.command == "create_and_generate_turn" || json.command == "generate_turn_candidate")
            {
                if (selectedCharExternalId != "") {
                    json.payload.character_id = selectedCharExternalId;
                }
                args[0] = JSON.stringify(json);
            }
        } catch (ex) {
            alert(ex);
        }

        if (neo_socket != this) {
            neo_socket = this;
            neo_socket.addEventListener("message", onMessage);
        }

        func.call(this, ...args);
    }
})();