Triangulet Chat Integration

Adds a chat feature to Triangulet

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Triangulet Chat Integration
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Adds a chat feature to Triangulet
// @author       fsscooter
// @match        *://tri.pengpowers.xyz/*
// @match        *://coplic.com/*
// @icon         https://tri.pengpowers.xyz/media/misc/favicon.png
// @grant        none
// @license      none
// ==/UserScript==

(function() {
    'use strict';

    function addChatTab() {
        const sidebar = document.querySelector('.styles__sidebar___1XqWi-camelCase');
        if (!sidebar || sidebar.dataset.chatTabAdded) return;

        const existingTab = document.querySelector('.styles__pageButton___1wFuu-camelCase');
        if (!existingTab) return;

        const newTab = existingTab.cloneNode(true);
        newTab.href = "/stats?chat=true";
        newTab.querySelector('.styles__pageIcon___3OSy9-camelCase').className =
            "styles__pageIcon___3OSy9-camelCase fas fa-comments";
        newTab.querySelector('.styles__pageText___1eo7q-camelCase').textContent = "Chat";

        const bottomRow = document.querySelector('.styles__bottomRow___3OozA-camelCase');
        if (bottomRow) {
            sidebar.insertBefore(newTab, bottomRow);
            sidebar.dataset.chatTabAdded = "true";
        }
    }

    if (window.location.href.includes("/stats?chat=true")) {
        transformToChat();
    }

    addChatTab();
    const observer = new MutationObserver(addChatTab);
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    function transformToChat() {
        const profileBody = document.querySelector('.arts__profileBody___eNPbH-camelCase');
        if (profileBody) profileBody.style.display = "none";

        const topRightRow = document.querySelector('.styles__topRightRow___dQvxc-camelCase');
        if (topRightRow) {
            topRightRow.insertAdjacentHTML('afterbegin', `
                <div class="styles__profileContainer___CSuIE-camelCase" role="button" tabindex="0">
                    <div class="styles__profileRow___cJa4E-camelCase">
                        <div style="position: relative" class="styles__blookContainer___36LK2-camelCase styles__profileBlook___37mfP-camelCase">
                            <img src="https://i.ibb.co/r2gYyjdJ/output-onlinepngtools-3.png" id="status" draggable="false" class="styles__blook___1R6So-camelCase">
                        </div>
                        <span style="color: #ffffff" id="usersnamedrop">0 Users Online</span>
                    </div>
                    <i class="fas fa-angle-down styles__profileDropdownIcon___3iLIX-camelCase" aria-hidden="true"></i>
                    <div class="styles__profileDropdownMenu___2jUAA-camelCase" id="online-users-dropdown" style="max-height: 300px; overflow-y: auto;"></div>
                </div>
            `);
        }

        const style = document.createElement('style');
        style.textContent = `
            #chat-container {
                height: 502px;
                width: 80%;
                max-width: 1114px;
                overflow-y: auto;
                padding: 10px;
                margin-bottom: 10px;
                background-color: rgba(0, 0, 0, 0);
                margin-left: 220px;
            }

            .chat-message {
                display: flex;
                align-items: center;
                margin-bottom: 8px;
                color: #fff;
            }

            .styles__infoContainer___2uI-S-camelCase {
                display: flex;
                align-items: center;
                gap: 10px;
                width: 1000px;
                padding: 12px;
                position: fixed;
                bottom: 0;
                left: 25%;
                transform: translateX(-10%);
            }

            .styles__infoContainer___2uI-S-camelCase i {
                color: #fff;
                font-size: 20px;
                cursor: pointer;
                flex-shrink: 0;
                margin-bottom: -35px;
                transform: translateX(-2445%);
            }

            #user-input {
                width: 95%;
                padding: 5px;
                font-size: 16px;
                font-family: 'Nunito', sans-serif;
                background: rgba(0, 0, 0, 0.5);
                color: #fff;
                border: none;
                outline: none;
                border-radius: 5px;
                display: inline-block;
                transform: translateX(1%);
            }

            #typing-indicator {
                margin: -5px 0 10px 220px;
                padding-left: 20px;
                font-style: italic;
                color: #ffff;
                font-size: 14px;
            }

            #new-message {
                display: none;
                margin: -12px 0 10px 220px;
                padding-left: 400px;
                font-weight: bold;
                color: #fff;
                font-size: 18px;
                cursor: pointer;
            }

            .profile-link {
                text-decoration: none;
                color: inherit;
            }
        `;
        document.head.appendChild(style);

        const chatHTML = `
            <div id="chat-container"></div>
            <div id="typing-indicator"></div>
            <div id="new-message"></div>
            <div class="styles__infoContainer___2uI-S-camelCase">
                <i class="fas fa-upload" style="cursor: pointer;" onclick="document.getElementById('fileInput').click();"></i>
                <input type="file" id="fileInput" accept="image/*,video/*,audio/*" style="display: none;">
                <input type="text" id="user-input" placeholder="Type a message..."/>
            </div>
        `;
        document.body.insertAdjacentHTML('beforeend', chatHTML);

        initializeChat();
    }

    function initializeChat() {
        const firebaseScript = document.createElement('script');
        firebaseScript.src = 'https://www.gstatic.com/firebasejs/8.10.0/firebase-app.js';

        firebaseScript.onload = () => {
            const firebaseDatabaseScript = document.createElement('script');
            firebaseDatabaseScript.src = 'https://www.gstatic.com/firebasejs/8.10.0/firebase-database.js';

            firebaseDatabaseScript.onload = () => {
                const firebaseConfig = {
                    apiKey: "AIzaSyDV9tQXgzqxUayhvc384tTLOwy0QOEZVcU",
                    authDomain: "chat-e6c93.firebaseapp.com",
                    databaseURL: "https://chat-e6c93-default-rtdb.firebaseio.com",
                    projectId: "chat-e6c93",
                    storageBucket: "chat-e6c93.appspot.com",
                    messagingSenderId: "131547791719",
                    appId: "1:131547791719:web:2f567033f028810345afc2",
                    measurementId: "G-VY49LNJJLG"
                };

                const app = firebase.initializeApp(firebaseConfig);
                const db = firebase.database();
                const chatRef = db.ref('triangulet1/');
                const typingRef = db.ref('triangulet_typing/');
                const onlineRef = db.ref('triangulet_online/');

                const PAGE_SIZE = 20;
                let earliestTimestamp = null;
                let loadingOlderMessages = false;
                let loadedMessages = new Set();
                let firstLoadDone = false;

                const token = document.cookie
                    .split('; ')
                    .find(row => row.startsWith('tokenraw='))
                    ?.split('=')[1];

                let currentUser = "User";
                let currentUserPfp = "https://i.ibb.co/5GBHSTB/Triangulet-Game-Logo.png";
                let currentUserId = "";

                fetch('/data/user', {
                    headers: {
                        'Authorization': decodeURIComponent(token)
                    }
                })
                .then(res => res.json())
                .then(data => {
                    if (data.username) {
                        currentUser = data.username;
                        if (data.pfp) {
                            currentUserPfp = data.pfp;
                        }
                        currentUserId = data.id;
                        initializeChatComponents();
                    } else {
                        console.error("Username not found in response");
                        initializeChatComponents();
                    }
                })
                .catch(err => {
                    console.error("Error fetching username:", err);
                    initializeChatComponents();
                });

                function initializeChatComponents() {
                    const userKey = currentUser.replace(/\W+/g, "_");
                    const userStatusRef = onlineRef.child(userKey);

                    userStatusRef.set({
                        username: currentUser,
                        userId: currentUserId,
                        timestamp: firebase.database.ServerValue.TIMESTAMP
                    });

                    const onlineStatusInterval = setInterval(() => {
                        userStatusRef.update({
                            timestamp: firebase.database.ServerValue.TIMESTAMP
                        });
                    }, 1000);

                    userStatusRef.onDisconnect().remove();

                    const chatContainer = document.getElementById("chat-container");
                    const userInput = document.getElementById("user-input");
                    const typingIndicator = document.getElementById("typing-indicator");
                    const newMessageBanner = document.getElementById("new-message");
                    const usersNameDrop = document.getElementById("usersnamedrop");
                    const usersDropdown = document.getElementById("online-users-dropdown");

                    function updateUserListDisplay(userList) {
                        usersDropdown.innerHTML = "";

                        userList.forEach(user => {
                            const safeUser = escapeHtml(user.username);
                            const safeUserId = escapeHtml(user.userId || '');
                            const displayName = safeUser.length > 15 ?
                                escapeHtml(safeUser.slice(0, 15)) + "..." :
                                safeUser;

                            const item = document.createElement("a");
                            item.className = "styles__profileDropdownOption___ljZXD-camelCase profile-link";
                            item.href = `https://tri.pengpowers.xyz/stats?id=${safeUserId}`;
                            item.style.color = "#ffffff";
                            item.innerHTML = `
                                <i class="fas fa-user styles__profileDropdownOptionIcon___15VKX-camelCase" style="color: #ffffff;"></i>
                                <span title="${safeUser}">${displayName}</span>
                            `;
                            usersDropdown.appendChild(item);
                        });

                        usersNameDrop.textContent = `${userList.length} User${userList.length !== 1 ? 's' : ''} Online`;
                        usersDropdown.style.maxHeight = userList.length > 15 ? "300px" : "unset";
                        usersDropdown.style.overflowY = userList.length > 15 ? "auto" : "unset";
                    }

                    onlineRef.on('value', (snapshot) => {
                        const data = snapshot.val() || {};
                        const users = Object.values(data)
                            .filter(entry => entry.username)
                            .sort((a, b) => a.username.localeCompare(b.username));
                        updateUserListDisplay(users);
                    });

                                       function sanitizeHtml(html) {
    const temp = document.createElement('div');
    temp.textContent = html;
    return temp.innerHTML
        .replace(/javascript:/gi, '')
        .replace(/on\w+="[^"]*"/gi, '');
}

function escapeHtml(text) {
    return text
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;")
        .replace(/"/g, "&quot;")
        .replace(/'/g, "&#039;");

}

                    function formatTimestamp(timestamp) {
                        const date = new Date(timestamp);
                        const now = new Date();
                        const options = { hour: 'numeric', minute: '2-digit', hour12: true };

                        if (date.toDateString() === now.toDateString()) {
                            return date.toLocaleTimeString(undefined, options);
                        }
                        return date.toLocaleString(undefined, {
                            month: 'short',
                            day: 'numeric',
                            ...options
                        });
                    }

                    function renderMessage(text) {
                        const blockedDomains = [
                            "iplogger","wl.gl","ed.tc","bc.ax","maper.info","2no.co","yip.su",
                            "iplis.ru","ezstat.ru","iplog.co","iplogger.cn","grabify","hd.gd",
                            "onbit.pro","snifferip.com","unl.one","urlto.me","location.cyou",
                            "mymap.icu","mymap.quest","map-s.online","crypto-o.click","cryp-o.online",
                            "account.beauty","photospace.life","photovault.store","imagehub.fun",
                            "sharevault.cloud","xtube.chat","screensnaps.top","photovault.pics",
                            "foot.wiki","gamergirl.pro","picshost.pics","pichost.pics","imghost.pics",
                            "screenshare.pics","myprivate.pics","shrekis.life","screenshot.best",
                            "gamingfun.me","stopify.co"
                        ];

                        function isBlockedUrl(url) {
                            try {
                                const lowered = url.toLowerCase();
                                return blockedDomains.some(domain => lowered.includes(domain));
                            } catch {
                                return false;
                            }
                        }

                        const trimmedText = text.trim();
                        const cleanText = sanitizeHtml(trimmedText);

                        if (cleanText.startsWith("data:")) {
                            const mime = cleanText.slice(5, cleanText.indexOf(";"));
                            if (mime.startsWith("image/")) {
                                return `<img src="${sanitizeHtml(cleanText)}" style="max-width: 300px; max-height: 300px; border-radius: 6px;">`;
                            } else if (mime.startsWith("video/")) {
                                return `<video controls style="max-width: 300px; max-height: 300px;">
                                            <source src="${sanitizeHtml(cleanText)}" type="${sanitizeHtml(mime)}">
                                            Your browser does not support the video tag.
                                        </video>`;
                            } else if (mime.startsWith("audio/")) {
                                return `<audio controls>
                                            <source src="${sanitizeHtml(cleanText)}" type="${sanitizeHtml(mime)}">
                                            Your browser does not support the audio tag.
                                        </audio>`;
                            }
                        }

                        const urlRegex = /^https?:\/\/[^\s]+$/i;
                        const imageUrlPattern = /(https?:\/\/.*\.(?:jpeg|jpg|gif|png|svg|webp|tiff|eps|bmp|avif|xcf|ico))/i;
                        const videoUrlPattern = /(https?:\/\/.*\.(?:avi|mov|mp4|ogg|wmv|mkv|mpg|flv|avchd|mpeg4|m2ts|webm))/i;
                        const audioUrlPattern = /(https?:\/\/.*\.(?:mp3|wav|aac|pcm|m4a|m4p|opus|flac|dsd|gsm|wma|ogg))/i;

                        if (urlRegex.test(cleanText)) {
                            if (isBlockedUrl(cleanText)) {
                                return escapeHtml(cleanText);
                            }

                            if (imageUrlPattern.test(cleanText)) {
                                return `<img src="${sanitizeHtml(cleanText)}" style="max-width: 300px; max-height: 300px; border-radius: 6px;">`;
                            } else if (videoUrlPattern.test(cleanText)) {
                                return `<video controls style="max-width: 300px; max-height: 300px;">
                                            <source src="${sanitizeHtml(cleanText)}">
                                            Your browser does not support the video tag.
                                        </video>`;
                            } else if (audioUrlPattern.test(cleanText)) {
                                return `<audio controls>
                                            <source src="${sanitizeHtml(cleanText)}">
                                            Your browser does not support the audio tag.
                                        </audio>`;
                            } else {
                                return `<a href="${escapeHtml(cleanText)}" target="_blank" rel="noopener noreferrer">${escapeHtml(cleanText)}</a>`;
                            }
                        }

                        return escapeHtml(cleanText);
                    }

                    function appendMessage(sender, text, timestamp, pfp, userId, prepend = false) {
                        const safeSender = escapeHtml(sender.length > 15 ? sender.slice(0, 15) + "..." : sender);
                        const safeUserId = escapeHtml(userId || '');
                        const mentionRegex = /@(\w{1,30})/g;
                        const pingSound = new Audio("https://cdn.glitch.global/a6695a81-c90d-4020-ae20-474929cf2986/Blacket%20Reply%20SFX%20(mp3cut.net)%20(1).mp3?v=1749595919054");
                        let containsMention = false;

                        const processedText = escapeHtml(text).replace(mentionRegex, (_, m) => {
                            const safe = escapeHtml(m);
                            if (safe.toLowerCase() === currentUser.toLowerCase() || safe.toLowerCase() === "everyone") {
                                containsMention = true;
                            }
                            return `<span style="color: blue; font-weight: bold;">@${safe}</span>`;
                        });

                        if (containsMention) {
                            pingSound.play().catch(() => {});
                        }

                        const safePfp = pfp ? sanitizeHtml(pfp) : 'https://i.ibb.co/5GBHSTB/Triangulet-Game-Logo.png';
                        const msgStyle = containsMention ?
                            "background-color: yellow; padding: 5px; border-radius: 4px; color: black;" :
                            "color: white;";

                        const formattedTime = formatTimestamp(timestamp);
                        const html = `
                            <div class="chat-message" style="display: flex; align-items: flex-start; margin-bottom: 15px; gap: 10px;">
                                <a href="https://tri.pengpowers.xyz/stats?id=${safeUserId}" class="profile-link">
                                    <img src="${safePfp}" alt="User Icon" style="width: 50px; height: 50px; border-radius: 0;">
                                </a>
                                <div>
                                    <div style="display: flex; align-items: center; gap: 10px; margin-bottom: 5px;">
                                        <a href="https://tri.pengpowers.xyz/stats?id=${safeUserId}" class="profile-link">
                                            <strong style="font-size: 1.2em; color: white;">${safeSender}</strong>
                                        </a>
                                        <span style="font-size: 0.85em; color: white;">${formattedTime}</span>
                                    </div>
                                    <span style="font-size: 1em; word-break: break-word; ${msgStyle}">
                                        ${renderMessage(processedText)}
                                    </span>
                                </div>
                            </div>`;

                        if (prepend) {
                            const prevScroll = chatContainer.scrollHeight;
                            chatContainer.insertAdjacentHTML('afterbegin', html);
                            const diff = chatContainer.scrollHeight - prevScroll;
                            chatContainer.scrollTop += diff;
                        } else {
                            chatContainer.insertAdjacentHTML('beforeend', html);
                            if (isNearBottom()) {
                                chatContainer.scrollTop = chatContainer.scrollHeight;
                                newMessageBanner.style.display = "none";
                            } else {
                                newMessageBanner.textContent = "New messages";
                                newMessageBanner.style.display = "block";
                            }
                        }
                    }

                    function isNearBottom() {
                        return Math.abs(chatContainer.scrollHeight - chatContainer.scrollTop - chatContainer.clientHeight) < 100;
                    }

                    async function loadMessages(initial = false) {
                        if (loadingOlderMessages) return;
                        loadingOlderMessages = true;

                        let queryRef;
                        if (initial) {
                            queryRef = chatRef.orderByChild("timestamp").limitToLast(PAGE_SIZE);
                        } else if (earliestTimestamp) {
                            queryRef = chatRef.orderByChild("timestamp").endAt(earliestTimestamp).limitToLast(PAGE_SIZE + 1);
                        } else {
                            loadingOlderMessages = false;
                            return;
                        }

                        try {
                            const snapshot = await queryRef.once('value');
                            const data = snapshot.val();
                            if (!data) return;

                            let messages = Object.entries(data).map(([id, msg]) => ({ id, ...msg }));
                            messages.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
                            if (!initial) messages.pop();

                            for (const msg of messages) {
                                if (!loadedMessages.has(msg.id)) {
                                    appendMessage(
                                        msg.username || "User",
                                        msg.text,
                                        msg.timestamp,
                                        msg.pfp,
                                        msg.userId,
                                        !initial
                                    );
                                    loadedMessages.add(msg.id);
                                    if (!earliestTimestamp || new Date(msg.timestamp) < new Date(earliestTimestamp)) {
                                        earliestTimestamp = msg.timestamp;
                                    }
                                }
                            }

                            if (initial) {
                                setTimeout(() => {
                                    chatContainer.scrollTop = chatContainer.scrollHeight;
                                }, 0);
                                firstLoadDone = true;
                            }
                        } finally {
                            loadingOlderMessages = false;
                        }
                    }

                    chatRef.on('child_added', (snapshot) => {
                        const msg = snapshot.val();
                        const id = snapshot.key;
                        if (!loadedMessages.has(id) && firstLoadDone) {
                            appendMessage(
                                msg.username || "User",
                                msg.text,
                                msg.timestamp,
                                msg.pfp,
                                msg.userId
                            );
                            loadedMessages.add(id);
                        }
                    });

                    chatContainer.addEventListener("scroll", () => {
                        if (chatContainer.scrollTop < 100 && !loadingOlderMessages) {
                            loadMessages(false);
                        }
                        if (isNearBottom()) {
                            newMessageBanner.style.display = "none";
                        }
                    });

                    newMessageBanner.addEventListener("click", () => {
                        chatContainer.scrollTop = chatContainer.scrollHeight;
                        newMessageBanner.style.display = "none";
                    });

                    let typingTimeout;
                    userInput.addEventListener("input", () => {
                        typingRef.child(userKey).set({
                            username: currentUser,
                            timestamp: firebase.database.ServerValue.TIMESTAMP
                        });
                        clearTimeout(typingTimeout);
                        typingTimeout = setTimeout(() => {
                            typingRef.child(userKey).remove();
                        }, 3000);
                    });

                    typingRef.on('value', (snapshot) => {
                        const data = snapshot.val() || {};
                        const typers = Object.values(data)
                            .filter(entry => entry.username && entry.username.toLowerCase() !== currentUser.toLowerCase())
                            .map(entry => escapeHtml(entry.username));

                        if (typers.length === 0) {
                            typingIndicator.textContent = "";
                        } else if (typers.length === 1) {
                            typingIndicator.textContent = `${typers[0]} is typing...`;
                        } else {
                            const displayed = typers.slice(0, 2).join(", ");
                            const remaining = typers.length - 2;
                            typingIndicator.textContent = remaining > 0 ?
                                `${displayed}, and ${remaining} more are typing...` :
                                `${displayed} are typing...`;
                        }
                    });

                    userInput.addEventListener("keypress", (e) => {
                        if (e.key === "Enter" && !e.shiftKey) {
                            e.preventDefault();
                            sendMessage();
                        }
                    });

                    async function sendMessage() {
                        const text = userInput.value.trim();
                        if (!text) return;

                        try {
                            await chatRef.push({
                                text,
                                username: currentUser,
                                pfp: currentUserPfp,
                                userId: currentUserId,
                                timestamp: firebase.database.ServerValue.TIMESTAMP
                            });
                            userInput.value = '';
                            typingRef.child(userKey).remove();
                        } catch (err) {
                            console.error("Error sending message:", err);
                        }
                    }

                    const fileInput = document.getElementById("fileInput");
                    fileInput.addEventListener("change", (event) => {
                        const file = event.target.files[0];
                        if (!file) return;

                        const reader = new FileReader();
                        reader.onload = (e) => {
                            const base64 = e.target.result;
                            chatRef.push({
                                text: base64,
                                username: currentUser,
                                pfp: currentUserPfp,
                                userId: currentUserId,
                                timestamp: firebase.database.ServerValue.TIMESTAMP
                            });
                        };
                        reader.readAsDataURL(file);
                    });

                    loadMessages(true);
                }
            };

            document.head.appendChild(firebaseDatabaseScript);
        };

        document.head.appendChild(firebaseScript);
    }
})();