Spaces:
Running
Running
| const fs = require("fs"); | |
| const http = require("http"); | |
| const path = require("path"); | |
| const WebSocket = require("ws"); | |
| const { | |
| detectLanguageFromRequest, | |
| translateText, | |
| translationStatus | |
| } = require("./translation"); | |
| const { | |
| ENGLISH_PROFANITY_ROOTS, | |
| ENGLISH_PROFANITY_SUFFIXES, | |
| GENERATED_VARIANT_COUNT | |
| } = require("./profanity-words"); | |
| const { | |
| cleanModerationLanguage, | |
| filterMultilingualProfanity, | |
| multilingualModerationStatus | |
| } = require("./multilingual-profanity"); | |
| const PORT = process.env.PORT || 7860; | |
| const MAX_MESSAGE_LENGTH = 600; | |
| const MAX_HISTORY_PER_ROOM = 100; | |
| const MESSAGE_TTL_MS = 24 * 60 * 60 * 1000; | |
| const MESSAGE_COOLDOWN_MS = 1500; | |
| const HEARTBEAT_TIMEOUT_MS = 10 * 1000; | |
| const STALE_SOCKET_TIMEOUT_MS = 30 * 1000; | |
| const ROOM_INFO_BROADCAST_MS = 2000; | |
| const SERVER_STATUS_BROADCAST_MS = 10000; | |
| const MAX_WS_BUFFERED_BYTES = 1024 * 1024; | |
| const TERMINATE_WS_BUFFERED_BYTES = 4 * 1024 * 1024; | |
| const ADMIN_NAME = "\uD83D\uDC51Admin"; | |
| const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "global-chat.1"; | |
| const rooms = new Map(); | |
| const clients = new Map(); | |
| const roomClients = new Map(); | |
| const lastRoomOnlineCounts = new Map(); | |
| const mutedUsers = new Set(); | |
| const translationRateLimits = new Map(); | |
| let chatPaused = false; | |
| let lastCpuUsage = process.cpuUsage(); | |
| let lastCpuTime = process.hrtime.bigint(); | |
| let storageCache = null; | |
| let storageCacheAt = 0; | |
| let statusCache = null; | |
| let statusCacheAt = 0; | |
| let statusCacheJson = ""; | |
| const LEET_CHARACTER_PATTERNS = Object.freeze({ | |
| a: "[a@4\\u00e0-\\u00e5]", | |
| b: "[b8]", | |
| c: "[c\\u00e7(]", | |
| d: "d", | |
| e: "[e3\\u00e8-\\u00eb]", | |
| f: "f", | |
| g: "[g\\u011f69]", | |
| h: "h", | |
| i: "[i\\u0130\\u01311!|\\u00ec-\\u00ef]", | |
| j: "j", | |
| k: "k", | |
| l: "[l1|]", | |
| m: "m", | |
| n: "n", | |
| o: "[o0\\u00f2-\\u00f6]", | |
| p: "p", | |
| q: "q", | |
| r: "r", | |
| s: "[s\\u015f5$]", | |
| t: "[t7+]", | |
| u: "[u\\u00f9-\\u00fc]", | |
| v: "v", | |
| w: "w", | |
| x: "x", | |
| y: "y", | |
| z: "[z2]" | |
| }); | |
| const PROFANITY_SEPARATOR_PATTERN = "[\\s._*\\-]{0,8}"; | |
| const PROFANITY_PATTERN_CHUNK_SIZE = 24; | |
| function buildLetterPattern(text) { | |
| return Array.from(text) | |
| .map((character) => LEET_CHARACTER_PATTERNS[character] || character) | |
| .map((characterPattern) => `(?:${characterPattern}){1,6}`) | |
| .join(PROFANITY_SEPARATOR_PATTERN); | |
| } | |
| function buildProfanitySource(term) { | |
| const letters = String(term).toLowerCase().replace(/[^a-z]/g, ""); | |
| if (!letters) return null; | |
| const rootPattern = buildLetterPattern(letters); | |
| const suffixPatterns = ENGLISH_PROFANITY_SUFFIXES | |
| .filter(Boolean) | |
| .map((suffix) => buildLetterPattern(suffix)); | |
| const suffixPattern = suffixPatterns.length > 0 | |
| ? `(?:${suffixPatterns.join("|")})?` | |
| : ""; | |
| return `${rootPattern}${suffixPattern}`; | |
| } | |
| function buildProfanityPatterns(terms) { | |
| const sources = terms | |
| .slice() | |
| .sort((left, right) => right.length - left.length) | |
| .map(buildProfanitySource) | |
| .filter(Boolean); | |
| const patterns = []; | |
| for (let index = 0; index < sources.length; index += PROFANITY_PATTERN_CHUNK_SIZE) { | |
| const alternatives = sources.slice(index, index + PROFANITY_PATTERN_CHUNK_SIZE).join("|"); | |
| patterns.push(new RegExp( | |
| `(^|[^\\p{L}\\p{N}])(${alternatives})(?=$|[^\\p{L}\\p{N}])`, | |
| "giu" | |
| )); | |
| } | |
| return patterns; | |
| } | |
| const profanityPatterns = buildProfanityPatterns(ENGLISH_PROFANITY_ROOTS); | |
| function now() { | |
| return Date.now(); | |
| } | |
| function safeText(value, fallback = "") { | |
| if (typeof value !== "string") return fallback; | |
| return value.trim().slice(0, MAX_MESSAGE_LENGTH); | |
| } | |
| function cleanNickname(value, language = "en") { | |
| const nickname = safeText(value, "Player").slice(0, 24); | |
| const reservedName = nickname | |
| .toLowerCase() | |
| .replace(/[^a-z]/g, ""); | |
| if (!nickname || reservedName.includes("admin") || reservedName.includes("moderator")) { | |
| return "Player"; | |
| } | |
| return filterMessage(nickname, language); | |
| } | |
| function cleanRoomName(value) { | |
| const raw = safeText(value || "global", "global").toLowerCase(); | |
| const clean = raw.replace(/[^a-z0-9_-]/g, ""); | |
| return clean.length > 0 ? clean.slice(0, 40) : "global"; | |
| } | |
| function filterMessage(text, language = "en") { | |
| let filtered = filterMultilingualProfanity(text, language); | |
| for (const pattern of profanityPatterns) { | |
| pattern.lastIndex = 0; | |
| filtered = filtered.replace(pattern, (match, prefix, offensiveText) => { | |
| const replacementLength = Array.from(offensiveText).length; | |
| return `${prefix}${"*".repeat(replacementLength)}`; | |
| }); | |
| } | |
| return filtered; | |
| } | |
| function getRoom(roomName) { | |
| const room = cleanRoomName(roomName); | |
| if (!rooms.has(room)) { | |
| rooms.set(room, []); | |
| invalidateStatusCache(); | |
| } | |
| return room; | |
| } | |
| function isActiveClient(client) { | |
| return client && !client.isAdmin && now() - client.lastSeen <= HEARTBEAT_TIMEOUT_MS; | |
| } | |
| function activeClientsCount() { | |
| let count = 0; | |
| for (const client of clients.values()) { | |
| if (isActiveClient(client)) count++; | |
| } | |
| return count; | |
| } | |
| function countClientsInRoom(room) { | |
| let count = 0; | |
| const members = roomClients.get(room); | |
| if (!members) return 0; | |
| for (const ws of members) { | |
| const client = clients.get(ws); | |
| if (isActiveClient(client)) count++; | |
| } | |
| return count; | |
| } | |
| function addClientToRoom(ws, client, room) { | |
| const previousRoom = client.room; | |
| if (previousRoom === room) return previousRoom; | |
| if (previousRoom) { | |
| const previousMembers = roomClients.get(previousRoom); | |
| if (previousMembers) { | |
| previousMembers.delete(ws); | |
| if (previousMembers.size === 0) roomClients.delete(previousRoom); | |
| } | |
| } | |
| client.room = room; | |
| if (!roomClients.has(room)) roomClients.set(room, new Set()); | |
| roomClients.get(room).add(ws); | |
| return previousRoom; | |
| } | |
| function removeClientFromRoom(ws, client) { | |
| const room = client?.room; | |
| if (!room) return ""; | |
| const members = roomClients.get(room); | |
| if (members) { | |
| members.delete(ws); | |
| if (members.size === 0) roomClients.delete(room); | |
| } | |
| client.room = ""; | |
| return room; | |
| } | |
| function findRoomForJoin(baseRoom, requestedLimit) { | |
| const limit = Math.max(0, Math.floor(Number(requestedLimit) || 0)); | |
| if (limit <= 0) return getRoom(baseRoom); | |
| let index = 1; | |
| while (true) { | |
| const room = index === 1 ? baseRoom : `${baseRoom}-${index}`; | |
| getRoom(room); | |
| if (countClientsInRoom(room) < limit) return room; | |
| index++; | |
| } | |
| } | |
| function cleanRoom(room) { | |
| const history = rooms.get(room) || []; | |
| const cutoff = now() - MESSAGE_TTL_MS; | |
| const cleaned = history | |
| .filter((msg) => msg.time >= cutoff) | |
| .slice(-MAX_HISTORY_PER_ROOM); | |
| rooms.set(room, cleaned); | |
| return cleaned; | |
| } | |
| function serializePacket(type, payload = {}) { | |
| return JSON.stringify({ type, ...payload }); | |
| } | |
| function sendSerialized(ws, packet) { | |
| if (ws.readyState !== WebSocket.OPEN) return false; | |
| if (ws.bufferedAmount >= TERMINATE_WS_BUFFERED_BYTES) { | |
| ws.terminate(); | |
| return false; | |
| } | |
| if (ws.bufferedAmount >= MAX_WS_BUFFERED_BYTES) return false; | |
| try { | |
| ws.send(packet); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function send(ws, type, payload = {}) { | |
| return sendSerialized(ws, serializePacket(type, payload)); | |
| } | |
| function broadcast(room, type, payload = {}) { | |
| const members = roomClients.get(room); | |
| if (!members || members.size === 0) return 0; | |
| const packet = serializePacket(type, payload); | |
| let sent = 0; | |
| for (const ws of members) { | |
| if (sendSerialized(ws, packet)) sent++; | |
| } | |
| return sent; | |
| } | |
| function broadcastAll(type, payload = {}) { | |
| const packet = serializePacket(type, payload); | |
| let sent = 0; | |
| for (const ws of clients.keys()) { | |
| if (sendSerialized(ws, packet)) sent++; | |
| } | |
| return sent; | |
| } | |
| function broadcastRoomInfo(room, force = false) { | |
| const online = countClientsInRoom(room); | |
| if (!force && lastRoomOnlineCounts.get(room) === online) return; | |
| lastRoomOnlineCounts.set(room, online); | |
| broadcast(room, "roomInfo", { | |
| room, | |
| online | |
| }); | |
| } | |
| function broadcastEveryRoomInfo() { | |
| const roomNames = new Set([...rooms.keys(), ...roomClients.keys()]); | |
| for (const room of roomNames) { | |
| broadcastRoomInfo(room); | |
| } | |
| } | |
| function messageForClient(chatMessage, client) { | |
| const filteringEnabled = client?.profanityFilterEnabled !== false; | |
| const filtered = filteringEnabled && chatMessage.filtered; | |
| return { | |
| id: chatMessage.id, | |
| room: chatMessage.room, | |
| nickname: chatMessage.nickname, | |
| message: filtered ? chatMessage.message : chatMessage.originalMessage, | |
| filtered, | |
| admin: chatMessage.admin, | |
| time: chatMessage.time | |
| }; | |
| } | |
| function sendHistory(ws, client, room, history) { | |
| send(ws, "history", { | |
| room, | |
| messages: history.map((message) => messageForClient(message, client)) | |
| }); | |
| } | |
| function broadcastChatMessage(room, chatMessage) { | |
| const members = roomClients.get(room); | |
| if (!members || members.size === 0) return 0; | |
| const filteredPacket = serializePacket("message", messageForClient(chatMessage, { profanityFilterEnabled: true })); | |
| const originalPacket = serializePacket("message", messageForClient(chatMessage, { profanityFilterEnabled: false })); | |
| let sent = 0; | |
| for (const ws of members) { | |
| const client = clients.get(ws); | |
| const packet = client?.profanityFilterEnabled === false ? originalPacket : filteredPacket; | |
| if (sendSerialized(ws, packet)) sent++; | |
| } | |
| return sent; | |
| } | |
| function appendMessage(room, nickname, message, admin = false, language = "en") { | |
| const originalMessage = safeText(message); | |
| const filteredMessage = filterMessage(originalMessage, language); | |
| const chatMessage = { | |
| id: `${now()}-${Math.random().toString(16).slice(2)}`, | |
| room, | |
| nickname, | |
| originalMessage, | |
| message: filteredMessage, | |
| filtered: filteredMessage !== originalMessage, | |
| admin, | |
| time: now() | |
| }; | |
| const history = cleanRoom(room); | |
| history.push(chatMessage); | |
| rooms.set(room, history.slice(-MAX_HISTORY_PER_ROOM)); | |
| broadcastChatMessage(room, chatMessage); | |
| return chatMessage; | |
| } | |
| function bytes(value) { | |
| if (!Number.isFinite(value) || value < 0) return "0 B"; | |
| const units = ["B", "KB", "MB", "GB", "TB"]; | |
| let size = value; | |
| let index = 0; | |
| while (size >= 1024 && index < units.length - 1) { | |
| size /= 1024; | |
| index++; | |
| } | |
| return `${size.toFixed(size >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; | |
| } | |
| function getDirectorySize(directory) { | |
| let total = 0; | |
| try { | |
| for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { | |
| const entryPath = path.join(directory, entry.name); | |
| if (entry.isDirectory()) { | |
| total += getDirectorySize(entryPath); | |
| } else if (entry.isFile()) { | |
| total += fs.statSync(entryPath).size; | |
| } | |
| } | |
| } catch { | |
| } | |
| return total; | |
| } | |
| function getStorageStats() { | |
| if (storageCache && now() - storageCacheAt < 10000) { | |
| return storageCache; | |
| } | |
| const used = getDirectorySize(__dirname); | |
| storageCache = { | |
| used, | |
| usedText: bytes(used), | |
| label: "App files only" | |
| }; | |
| storageCacheAt = now(); | |
| return storageCache; | |
| } | |
| function getProcessCpuPercent() { | |
| const usageDelta = process.cpuUsage(lastCpuUsage); | |
| const currentTime = process.hrtime.bigint(); | |
| const elapsedMicros = Number(currentTime - lastCpuTime) / 1000; | |
| lastCpuUsage = process.cpuUsage(); | |
| lastCpuTime = currentTime; | |
| if (elapsedMicros <= 0) return 0; | |
| const usedMicros = usageDelta.user + usageDelta.system; | |
| return Math.max(0, Math.min(100, Math.round((usedMicros / elapsedMicros) * 100))); | |
| } | |
| function getServerStats() { | |
| const memory = process.memoryUsage(); | |
| const cpuPercent = getProcessCpuPercent(); | |
| const storage = getStorageStats(); | |
| return { | |
| cpu: { | |
| percent: cpuPercent, | |
| label: "Node process CPU" | |
| }, | |
| memory: { | |
| rss: memory.rss, | |
| heapUsed: memory.heapUsed, | |
| heapTotal: memory.heapTotal, | |
| external: memory.external, | |
| rssText: bytes(memory.rss), | |
| heapUsedText: bytes(memory.heapUsed), | |
| heapTotalText: bytes(memory.heapTotal) | |
| }, | |
| storage, | |
| overloaded: false | |
| }; | |
| } | |
| function statusPayload(cacheMs = 0) { | |
| if (cacheMs > 0 && statusCache && now() - statusCacheAt < cacheMs) { | |
| return statusCache; | |
| } | |
| const stats = getServerStats(); | |
| const payload = { | |
| ok: true, | |
| paused: chatPaused, | |
| overloaded: stats.overloaded, | |
| clients: activeClientsCount(), | |
| totalSockets: clients.size, | |
| rooms: rooms.size, | |
| moderation: { | |
| language: "Multilingual", | |
| manualEnglishRoots: ENGLISH_PROFANITY_ROOTS.length, | |
| generatedEnglishVariants: GENERATED_VARIANT_COUNT, | |
| multilingual: multilingualModerationStatus() | |
| }, | |
| translation: translationStatus(), | |
| stats, | |
| time: new Date().toISOString() | |
| }; | |
| if (cacheMs > 0) { | |
| statusCache = payload; | |
| statusCacheAt = now(); | |
| statusCacheJson = JSON.stringify(payload); | |
| } | |
| return payload; | |
| } | |
| function invalidateStatusCache() { | |
| statusCache = null; | |
| statusCacheAt = 0; | |
| statusCacheJson = ""; | |
| } | |
| function cachedStatusJson() { | |
| statusPayload(1000); | |
| return statusCacheJson; | |
| } | |
| function sendServerStatus(ws) { | |
| const status = statusPayload(1000); | |
| send(ws, "serverStatus", { | |
| paused: status.paused, | |
| overloaded: status.overloaded, | |
| stats: status.stats | |
| }); | |
| } | |
| function readIndexHtml() { | |
| try { | |
| return fs.readFileSync(path.join(__dirname, "index.html")); | |
| } catch { | |
| return Buffer.from("Admin panel missing."); | |
| } | |
| } | |
| function sendJsonResponse(res, statusCode, payload) { | |
| res.writeHead(statusCode, { | |
| "Content-Type": "application/json; charset=utf-8", | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Headers": "Content-Type, Accept-Language, X-Client-Language", | |
| "Access-Control-Allow-Methods": "GET, POST, OPTIONS" | |
| }); | |
| res.end(typeof payload === "string" ? payload : JSON.stringify(payload)); | |
| } | |
| function readJsonBody(req, maxBytes = 8192) { | |
| return new Promise((resolve, reject) => { | |
| const chunks = []; | |
| let total = 0; | |
| req.on("data", (chunk) => { | |
| total += chunk.length; | |
| if (total > maxBytes) { | |
| reject(new Error("Request body is too large.")); | |
| req.destroy(); | |
| return; | |
| } | |
| chunks.push(chunk); | |
| }); | |
| req.on("end", () => { | |
| try { | |
| const raw = Buffer.concat(chunks).toString("utf8"); | |
| resolve(raw ? JSON.parse(raw) : {}); | |
| } catch { | |
| reject(new Error("Invalid JSON.")); | |
| } | |
| }); | |
| req.on("error", reject); | |
| }); | |
| } | |
| function translationClientId(req) { | |
| return String(req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.socket?.remoteAddress || "unknown") | |
| .split(",")[0] | |
| .trim(); | |
| } | |
| function allowTranslationRequest(req) { | |
| const key = translationClientId(req); | |
| const currentTime = now(); | |
| const windowMs = 60 * 1000; | |
| const limit = 180; | |
| const entry = translationRateLimits.get(key); | |
| if (!entry || currentTime - entry.startedAt >= windowMs) { | |
| translationRateLimits.set(key, { startedAt: currentTime, count: 1 }); | |
| return true; | |
| } | |
| entry.count++; | |
| return entry.count <= limit; | |
| } | |
| const server = http.createServer(async (req, res) => { | |
| const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); | |
| if (req.method === "OPTIONS") { | |
| sendJsonResponse(res, 204, {}); | |
| return; | |
| } | |
| if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") { | |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | |
| res.end(readIndexHtml()); | |
| return; | |
| } | |
| if (requestUrl.pathname === "/health" || requestUrl.pathname === "/stats") { | |
| sendJsonResponse(res, 200, cachedStatusJson()); | |
| return; | |
| } | |
| if (requestUrl.pathname === "/locale" && req.method === "GET") { | |
| const locale = await detectLanguageFromRequest(req); | |
| sendJsonResponse(res, 200, { ok: true, ...locale }); | |
| return; | |
| } | |
| if (requestUrl.pathname === "/translate" && req.method === "POST") { | |
| if (!allowTranslationRequest(req)) { | |
| sendJsonResponse(res, 429, { | |
| ok: false, | |
| code: "rate_limited", | |
| message: "Server could not translate. Please wait." | |
| }); | |
| return; | |
| } | |
| try { | |
| const data = await readJsonBody(req); | |
| const result = await translateText({ | |
| text: data.text, | |
| targetLanguage: data.targetLanguage, | |
| mode: data.mode, | |
| provider: data.provider | |
| }); | |
| sendJsonResponse(res, 200, { ok: true, ...result }); | |
| } catch (error) { | |
| const code = error.code || "translation_failed"; | |
| console.error(`[translation] ${code}: ${error.message}`); | |
| sendJsonResponse(res, code === "filtered_message" ? 422 : 503, { | |
| ok: false, | |
| code, | |
| provider: error.provider || "", | |
| message: code === "filtered_message" | |
| ? "Filtered messages are not translated." | |
| : "Server could not translate." | |
| }); | |
| } | |
| return; | |
| } | |
| res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); | |
| res.end("Global Chat Server is running. Use /health for uptime checks."); | |
| }); | |
| const wss = new WebSocket.Server({ | |
| server, | |
| path: "/ws", | |
| clientTracking: false, | |
| perMessageDeflate: false, | |
| maxPayload: 8 * 1024 | |
| }); | |
| wss.on("connection", (ws) => { | |
| const client = { | |
| nickname: "Player", | |
| room: "", | |
| language: "en", | |
| profanityFilterEnabled: true, | |
| lastMessageTime: 0, | |
| lastSeen: now(), | |
| isAdmin: false | |
| }; | |
| clients.set(ws, client); | |
| invalidateStatusCache(); | |
| send(ws, "connected", { | |
| message: "Connected to Global Chat Server" | |
| }); | |
| sendServerStatus(ws); | |
| ws.on("message", (raw) => { | |
| let data; | |
| try { | |
| data = JSON.parse(raw.toString()); | |
| } catch { | |
| send(ws, "error", { message: "Invalid JSON." }); | |
| return; | |
| } | |
| client.lastSeen = now(); | |
| if (data.type === "ping") { | |
| if (typeof data.profanityFilter === "boolean") { | |
| client.profanityFilterEnabled = data.profanityFilter; | |
| } | |
| send(ws, "pong", { time: now() }); | |
| return; | |
| } | |
| if (data.type === "join") { | |
| client.language = cleanModerationLanguage(data.language) || "en"; | |
| if (typeof data.profanityFilter === "boolean") { | |
| client.profanityFilterEnabled = data.profanityFilter; | |
| } | |
| client.nickname = client.isAdmin ? ADMIN_NAME : cleanNickname(data.nickname, client.language); | |
| const baseRoom = getRoom(data.room); | |
| const selectedRoom = findRoomForJoin(baseRoom, data.roomLimit); | |
| const previousRoom = addClientToRoom(ws, client, selectedRoom); | |
| const history = cleanRoom(client.room); | |
| sendHistory(ws, client, client.room, history); | |
| if (previousRoom && previousRoom !== client.room) broadcastRoomInfo(previousRoom); | |
| broadcastRoomInfo(client.room); | |
| sendServerStatus(ws); | |
| return; | |
| } | |
| if (data.type === "message") { | |
| if (!client.room) { | |
| send(ws, "error", { message: "Join a room first." }); | |
| return; | |
| } | |
| if (chatPaused) { | |
| send(ws, "error", { | |
| code: "paused", | |
| message: "Admin stopped Global Chat. Please come back later." | |
| }); | |
| return; | |
| } | |
| const userKey = `${client.room}:${client.nickname.toLowerCase()}`; | |
| if (mutedUsers.has(userKey)) { | |
| send(ws, "error", { message: "You are muted." }); | |
| return; | |
| } | |
| if (now() - client.lastMessageTime < MESSAGE_COOLDOWN_MS) { | |
| send(ws, "error", { message: "Slow down." }); | |
| return; | |
| } | |
| const rawText = safeText(data.message); | |
| if (!rawText) return; | |
| client.lastMessageTime = now(); | |
| appendMessage(client.room, client.nickname, rawText, false, client.language); | |
| return; | |
| } | |
| if (data.type === "admin") { | |
| if (!client.isAdmin) { | |
| if (data.password !== ADMIN_PASSWORD) { | |
| send(ws, "error", { message: "Wrong admin password." }); | |
| return; | |
| } | |
| client.isAdmin = true; | |
| client.nickname = ADMIN_NAME; | |
| client.lastSeen = now(); | |
| invalidateStatusCache(); | |
| if (client.room) broadcastRoomInfo(client.room); | |
| } | |
| if (data.action === "auth") { | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "pause") { | |
| chatPaused = true; | |
| invalidateStatusCache(); | |
| broadcastAll("serverStatus", { | |
| paused: chatPaused, | |
| overloaded: getServerStats().overloaded | |
| }); | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "resume") { | |
| chatPaused = false; | |
| invalidateStatusCache(); | |
| broadcastAll("serverStatus", { | |
| paused: chatPaused, | |
| overloaded: getServerStats().overloaded | |
| }); | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "message") { | |
| const room = getRoom(data.room || "global"); | |
| const adminMessage = safeText(data.message); | |
| if (adminMessage) appendMessage(room, ADMIN_NAME, adminMessage, true, client.language); | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "clear") { | |
| const room = getRoom(data.room || "global"); | |
| rooms.set(room, []); | |
| broadcast(room, "history", { room, messages: [] }); | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "mute") { | |
| const room = getRoom(data.room || "global"); | |
| const target = safeText(data.nickname).toLowerCase(); | |
| if (target) { | |
| mutedUsers.add(`${room}:${target}`); | |
| broadcast(room, "system", { message: `${target} muted by admin.` }); | |
| } | |
| send(ws, "adminReady", statusPayload()); | |
| return; | |
| } | |
| if (data.action === "unmute") { | |
| const room = getRoom(data.room || "global"); | |
| const target = safeText(data.nickname).toLowerCase(); | |
| if (target) { | |
| mutedUsers.delete(`${room}:${target}`); | |
| broadcast(room, "system", { message: `${target} unmuted by admin.` }); | |
| } | |
| send(ws, "adminReady", statusPayload()); | |
| } | |
| } | |
| }); | |
| ws.on("close", () => { | |
| const client = clients.get(ws); | |
| const room = removeClientFromRoom(ws, client); | |
| clients.delete(ws); | |
| invalidateStatusCache(); | |
| if (room) broadcastRoomInfo(room); | |
| }); | |
| ws.on("error", () => { | |
| // Close/terminate cleanup is handled by the close event. | |
| }); | |
| }); | |
| setInterval(() => { | |
| for (const room of rooms.keys()) { | |
| cleanRoom(room); | |
| if (countClientsInRoom(room) === 0 && (rooms.get(room) || []).length === 0) { | |
| rooms.delete(room); | |
| lastRoomOnlineCounts.delete(room); | |
| invalidateStatusCache(); | |
| } | |
| } | |
| for (const [clientId, entry] of translationRateLimits.entries()) { | |
| if (now() - entry.startedAt > 2 * 60 * 1000) { | |
| translationRateLimits.delete(clientId); | |
| } | |
| } | |
| }, 60 * 1000); | |
| setInterval(() => { | |
| broadcastEveryRoomInfo(); | |
| }, ROOM_INFO_BROADCAST_MS); | |
| setInterval(() => { | |
| const status = statusPayload(1000); | |
| broadcastAll("serverStatus", { | |
| paused: status.paused, | |
| overloaded: status.overloaded | |
| }); | |
| }, SERVER_STATUS_BROADCAST_MS); | |
| setInterval(() => { | |
| const cutoff = now() - STALE_SOCKET_TIMEOUT_MS; | |
| for (const [ws, client] of clients.entries()) { | |
| if (client.lastSeen < cutoff) ws.terminate(); | |
| } | |
| }, 10 * 1000); | |
| server.listen(PORT, "0.0.0.0", () => { | |
| console.log(`Global Chat Server running on port ${PORT}`); | |
| }); | |