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}`); });