openvibers/openvibe.live Commits
Delete Live's chat-server.js: every chat push and presence read goes through chat-delivery and chat-client; the local /ws/chat upgrade path is gone (OpenVibe.Chat serves it) (#30)
Changes
@@@ -49,7 +49,6 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s- **Files and restore drills:** every file location comes from [server/paths.js](server/paths.js) (`DATA_DIR`, `DB_PATH`), never a literal `./data`. Anything started at boot or at module load (timers, listeners, sockets, jobs, outbound calls) must stay off under `LIVE_DRILL` ([server/drill.js](server/drill.js)); `test/drill-mode.test.js` fails on any new one.- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).--- **ChatServer:** Singleton — `chat-server.js` exports `new ChatServer()`, not the class.## Key Pitfalls@@@ -64,7 +63,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s## WebSocket Endpoints--`/ws/chat`, `/ws/broadcast`, `/ws/control`, `/ws/call`, `/ws/robotstreamer-publish` — all upgraded via handler in `server/index.js` with origin checks and IP bans.++`/ws/broadcast`, `/ws/control`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server.## Testing@@@ -134,11 +134,13 @@ Recording, VODs and clips are produced by OpenVibe.Media from the live session## Chat```--Client WS → chat-server.js → SQLite (saveChatMessage) → room broadcast (stream / channel / global)++Client WS → OpenVibe.Chat (127.0.0.1:4400; nginx routes /ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds there)```--The room is derived from the stream the client joined; offline channel chat applies the channel's--bans and chat rules. Reconnects are jittered; a `server_restart` notice shows "OpenVibe is updating"--and refills history on reconnect.++Live runs no chat WebSocket server or chat REST routes: it delivers to Chat's typed ingress through++`server/chat/chat-delivery.js` (producer pushes, moderation, cache hints) and reads Chat's snapshot for++presence. The room is derived from the stream the client joined; offline channel chat applies the++channel's bans and chat rules. Reconnects are jittered; a `server_restart` notice shows "OpenVibe is++updating" and refills history on reconnect. See [chat-system.md](chat-system.md).## Server runtime@@@ -154,7 +156,7 @@ and refills history on reconnect.| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines || Database | `server/db/database.js` | queries and inline table setup || Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |--| Chat | `server/chat/chat-server.js` | WebSocket chat, history, moderation (REST: OpenVibe.Chat) |++| Chat delivery | `server/chat/chat-delivery.js` | Live's one seam to OpenVibe.Chat (pushes, moderation, cache hints, presence) || Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages || Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media || Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |@@@ -4,18 +4,19 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte## Architecture--- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing++- **WebSocket server**: OpenVibe.Chat (nginx sends `/ws/chat` there) — Live runs no chat server of its own- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync--### OpenVibe.Chat (roadmap Wave 6)++### OpenVibe.Chat (roadmap Wave 6; live since 2026-09-23)--Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)--with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on--(`server/chat/chat-authority.js`); without it nothing here changes.++Chat runs in [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400) with the++same WebSocket protocol and REST paths. Live runs no chat server in any mode (`CHAT_AUTHORITY=chat`++in production, `server/chat/chat-authority.js`); the local `ChatServer` and its `/ws/chat` upgrade++path are deleted.--- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers-- them with 503 if one still arrives, and `/ws/chat` upgrades are refused.++- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live mounts++ none of them, and a `/ws/chat` upgrade that still lands on Live is destroyed.- Live's chat producers (AI viewer lines, relayed and RobotStreamer chat, donations and alert sounds,cards, `/api/mod` deletes and moderation, cache hints) go through one seam,[server/chat/chat-delivery.js](../server/chat/chat-delivery.js), to Chat's typed service-token ingress@@@ -23,11 +24,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches`GET /internal/chat/presence`). Chat persists, broadcasts and speaks (`tts`) in one call peroperation; each carries one idempotency key reused on every retry. A Chat 4xx is logged and dropped; a 5xxor timeout is retried past Chat's five-minute delivery lease, then logged and dropped; neither is thrown.--- `require('./chat/chat-server')` returns a `RemoteChatServer` that never listens: synchronous reads-- (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from Chat's presence-- snapshot, polled every 3 s; Live's IP-approval and ban writes send Chat a cache hint; a push a module still-- makes on it goes to the same ingress, and TTS is never synthesised in Live. The arena commands-- (`/internal/chat-effects/arena-command`) answer the sender in the response's `replies`.++- `server/chat/chat-delivery.js` also carries the surface Live's remaining modules used on the old++ chat server: the push names (`broadcastToStream`, `broadcastToChannelRoom`, `sendDm`, …) go to the++ same ingress; synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's++ IP, anon ids) come from Chat's presence snapshot, polled every 3 s by `init()`; Live's IP-approval and++ ban writes send Chat a cache hint; a push with no ingress target (`forwardToGlobal`,++ `synthesizeAndBroadcastTTS`, …) is dropped and logged once, and TTS is never synthesised in Live. The++ arena commands (`/internal/chat-effects/arena-command`) answer the sender in the response's `replies`.- Deploy notices reach Chat only as the `live.release.deployed` event: with Events publishing off the commitsstay unannounced and the next boot tries again.- **The old ordered-calls bridge is gone** (T3 J2): no `chat-remote.js`, no `POST /internal/live/calls`, no@@@ -30,7 +30,6 @@ const express = require('express');const db = require('../db/database');const { requireAuth } = require('../auth/auth');const permissions = require('../auth/permissions');--const chatServer = require('../chat/chat-server');const delivery = require('../chat/chat-delivery');const chatReads = require('../chat/chat-reads');const ipUtils = require('./ip-utils');@@@ -110,7 +109,7 @@ function performGlobalBan(req, res, { userId, reason, durationHours, ipAddress }: null;// Auto-detect IP from connected WebSocket clients, then fall back to IP log-- let resolvedIp = ipAddress || chatServer.getConnectedUserIp(targetUser.id);++ let resolvedIp = ipAddress || delivery.getConnectedUserIp(targetUser.id);if (!resolvedIp) {const latest = db.getLatestIpForUser(targetUser.id);if (latest) resolvedIp = latest.ip_address;@@@ -234,7 +233,7 @@ router.post('/stream-ban', async (req, res) => {if (!targetUser) return res.status(404).json({ error: 'User not found' });// Auto-detect IP from connected clients if not provided-- const resolvedIp = ip_address || chatServer.getConnectedUserIp(targetUser.id);++ const resolvedIp = ip_address || delivery.getConnectedUserIp(targetUser.id);db.run(`INSERT INTO bans (stream_id, user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,@@@ -256,7 +255,7 @@ router.post('/stream-ban', async (req, res) => {res.json({ message: `${targetUser.username} banned from stream` });} else {// Anon ban — look up IP from connected anon client-- const anonClient = chatServer.findClientByAnonId(anon_id, streamId);++ const anonClient = delivery.findClientByAnonId(anon_id, streamId);const resolvedIp = ip_address || anonClient?.ip || null;db.run(@@@ -418,11 +417,11 @@ router.post('/delete-message', async (req, res) => {const delPayload = { type: 'delete-messages', ids: [parseInt(message_id)] };const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);if (delivery.ingress()) { /* broadcast by Chat */ } else if (chanUid || message.stream_id) {-- chatServer.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);-- if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);-- else chatServer.broadcastGlobal(delPayload);++ delivery.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);++ if (message.stream_id) delivery.forwardToGlobal(message.stream_id, delPayload);++ else delivery.broadcastGlobal(delPayload);} else {-- chatServer.broadcastGlobal(delPayload);++ delivery.broadcastGlobal(delPayload);}delivery.logModeration({@@@ -486,14 +485,11 @@ router.post('/delete-user-messages', async (req, res) => {const deleteMsg = { type: 'delete-messages', ids };if (scopedStreamId) {const chanUid = db.getStreamById(scopedStreamId)?.user_id || null;-- chatServer.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);-- chatServer.forwardToGlobal(scopedStreamId, deleteMsg);++ delivery.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);++ delivery.forwardToGlobal(scopedStreamId, deleteMsg);} else {-- // Global deletion — broadcast to every connected client-- const msg = JSON.stringify(deleteMsg);-- for (const [ws] of chatServer.clients) {-- if (ws.readyState === 1) ws.send(msg);-- }++ // Global deletion — Chat fans it out to the clients it holds; Live has none.++ delivery.broadcastGlobal(deleteMsg);}}@@@ -701,8 +697,8 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {timestamp: msg.created_at,was_held: true,};-- chatServer.broadcastToStream(msg.stream_id, chatMsg);-- chatServer.forwardToGlobal(msg.stream_id, chatMsg);++ delivery.broadcastToStream(msg.stream_id, chatMsg);++ delivery.forwardToGlobal(msg.stream_id, chatMsg);}}@@@ -802,7 +798,7 @@ router.get('/ip/user/:userId', permissions.requireGlobalMod, (req, res) => {const linked = db.getLinkedAccounts(userId);// Also check if the user is currently connected and get their live IP-- const liveIp = chatServer.getConnectedUserIp(userId);++ const liveIp = delivery.getConnectedUserIp(userId);res.json({user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, is_banned: user.is_banned, ban_reason: user.ban_reason, created_at: user.created_at },@@@ -824,7 +820,7 @@ router.get('/ip/anon/:anonId', permissions.requireGlobalMod, (req, res) => {const linked = db.getLinkedAccountsByAnon(anonId);// Try to find their live IP from connected clients-- const anonClient = chatServer.findClientByAnonId(anonId);++ const anonClient = delivery.findClientByAnonId(anonId);const liveIp = anonClient?.ip || null;const currentIp = liveIp || latest?.ip_address || null;@@@ -42,7 +42,6 @@ const path = require('path');const { execSync } = require('child_process');const db = require('../db/database');const { requireAuth } = require('../auth/auth');--const chatServer = require('../chat/chat-server');const delivery = require('../chat/chat-delivery');const chatReads = require('../chat/chat-reads');const permissions = require('../auth/permissions');@@@ -65,7 +64,7 @@ router.get('/diagnostics', (req, res) => {res.set('Cache-Control', 'no-store');res.json(require('../diagnostics').snapshot({db,-- chatServer: require('../chat/chat-server'),++ chatDelivery: require('../chat/chat-delivery'),broadcastServer: require('../streaming/broadcast-server'),callServer: (() => { try { return require('../streaming/call-server'); } catch { return null; } })(),restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),@@@ -253,7 +252,7 @@ router.put('/users/:id', (req, res) => {delivery.invalidate({user: id,user_data: { id, username: safeUser.username, display_name: safeUser.display_name || null, role: safeUser.role || null, avatar_url: safeUser.avatar_url || null, profile_color: safeUser.profile_color || null },-- }, () => chatServer.sendUserUpdate(id, safeUser));++ });}res.json({ user: safeUser });@@@ -227,7 +227,7 @@ function threadsBlock(channelUserId, limit) {}function viewerCountOf(stream) {-- try { const cs = require('../../chat/chat-server'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }++ try { const cs = require('../../chat/chat-delivery'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }return stream && stream.viewer_count != null ? stream.viewer_count : null;}@@@ -85,10 +85,9 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})filtered: false, timestamp: new Date().toISOString(),};try {-- const chatServer = require('../../chat/chat-server');-- chatServer.broadcastToStream(streamId, chatMsg);-- chatServer.forwardToGlobal(streamId, chatMsg);-- if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);++ delivery.broadcastToStream(streamId, chatMsg);++ delivery.forwardToGlobal(streamId, chatMsg);++ if (ttsOn) delivery.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);} catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }forwardToPowerChat(worker, bot, message, id);@@@ -50,7 +50,7 @@ class Scheduler {linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }_slowModeMs() {-- try { const cs = require('../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }++ try { const cs = require('../../chat/chat-delivery'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }}_arm(delay = null) {@@@ -1,7 +1,7 @@/*** voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read* out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the-- * per-identity auto voice chat already gives them (same `user:<username>` key as chat-server).++ * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).** Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),* served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply@@@ -6,14 +6,16 @@* (nginx routes them there) and owns the chat tables. Live then* - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;* - delivers every chat call its other modules make through Chat's typed ingress-- * (server/chat/chat-delivery.js → chat-client.js); require('./chat/chat-server') returns a-- * RemoteChatServer that reads Chat's presence and never listens;++ * (server/chat/chat-delivery.js → chat-client.js), which also carries the push names, the++ * presence reads and the address/anon helpers Live's modules used on its old chat server — Live++ * runs no chat server in any mode;* - reads chat stats, queues and history from Chat's internal read API* (server/chat/chat-reads.js → chat-client.js), and still keeps its chat tables as a read* mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved* yet keep working and a rollback loses nothing;* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.-- * Anything else (default): Live runs chat itself, exactly as before.++ * Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push++ * is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.*/const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');@@@ -1,18 +1,17 @@'use strict';/**-- * The one seam Live's chat producers call (T3 J2).++ * The one seam Live's chat producers call (T3 J2; Live's own ChatServer is deleted).** CHAT_AUTHORITY=chat: OpenVibe.Chat's typed ingress (chat-client.js) — Chat persists, broadcasts, mirrors,* speaks and moderates in one call per operation. This is the only path to Chat: the old ordered-calls bridge-- * and its outbox are gone.-- * Otherwise (rollback / dev): Live's own ChatServer — require('./chat-server').++ * and its outbox are gone, and Live no longer runs a chat server in any mode. This module also carries the++ * ChatServer surface Live's remaining modules still use.*-- * A Chat 4xx/5xx is logged and counted by chat-client.js and never thrown into the caller. Every function-- * here returns a promise in ingress mode and the local call's own value otherwise.++ * A Chat 4xx/5xx is logged and counted by chat-client.js and never thrown into the caller.** ingress() which path is active (CHAT_AUTHORITY=chat)* message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>-- * event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user, id, stream }++ * event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user | Chat's own kinds }* moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>* logModeration(entry) a moderation-log row (db.logModerationAction shape)* disconnect({ userId, ip, streamId })@@@ -20,13 +19,24 @@* mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP* rows, hidden relay users, first chats, TTS overrides) that Live still reads* after(value, fn) fn(value) now for a plain value, after it resolves for a promise++ *++ * The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,++ * broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate, disconnectUser — and go++ * to the same ingress. A push Chat's ingress has no target for (forwardToGlobal, forwardToStreamerRooms,++ * triggerChannelSound, synthesizeAndBroadcastTTS — Chat speaks a message's `tts` field and plays channel++ * sounds from its own settings) is dropped and logged once, exactly as the old RemoteChatServer did.++ *++ * Synchronous reads came from Chat's presence snapshot (GET /internal/chat/presence), polled every few++ * seconds by init()/close() (called from server/index.js at boot and drain): connection and viewer counts,++ * slow modes, a connected user's address. Live's own address/anon helpers live here too (call-server,++ * Chat's /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)++ * which send Chat a cache hint instead of waiting for its cache to expire.*/++const db = require('../db/database');const chatAuthority = require('./chat-authority');const client = require('./chat-client');function ingress() { return chatAuthority.isRemote(); }--// Lazy: chat-server pulls in most of Live, and several producers are required by it.--function chatServer() { return require('./chat-server'); }function defined(o) {const out = {};@@@ -50,19 +60,19 @@ function message(body) {return client.message(b).then((r) => (r && r.id != null ? Number(r.id) : null));}++// Pushes with no Chat ingress target: logged once per name, never synthesised.++const _dropped = new Set();++function dropped(name, why = 'has no Chat ingress target and was dropped') {++ if (_dropped.has(name)) return;++ _dropped.add(name);++ console.warn(`[Chat] ${name} ${why}`);++}++function event(target, frame, { key } = {}) {-- if (ingress()) {-- const t = { kind: target.kind };-- if (target.id != null) t.id = Number(target.id);-- return client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });-- }-- const cs = chatServer();-- if (target.kind === 'stream') return cs.broadcastToStream(target.id, frame);-- if (target.kind === 'channel') return cs.broadcastToChannelRoom(target.id, target.stream || null, frame);-- if (target.kind === 'global') return cs.broadcastGlobal(frame);-- if (target.kind === 'all') return cs.broadcastAll(frame);-- if (target.kind === 'user') return cs.sendDm(target.id, frame);-- throw new Error(`unknown chat target ${target.kind}`);++ if (!ingress()) { dropped(`event(${target.kind})`, 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }++ const t = { kind: target.kind };++ if (target.id != null) t.id = Number(target.id);++ return client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });}function moderate(action, fields, { key } = {}) {@@@ -71,7 +81,7 @@ function moderate(action, fields, { key } = {}) {const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];function logModeration(entry) {-- if (!ingress()) return require('../db/database').logModerationAction(entry);++ if (!ingress()) return db.logModerationAction(entry);const fields = {};for (const k of LOG_FIELDS) if (entry[k] != null) fields[k] = entry[k];if (typeof fields.scope_id === 'string' && fields.scope_type !== 'room' && /^\d+$/.test(fields.scope_id)) fields.scope_id = Number(fields.scope_id);@@@ -79,9 +89,10 @@ function logModeration(entry) {}function disconnect({ userId, ip, streamId } = {}) {-- if (!ingress()) return chatServer().disconnectUser(defined({ userId, ip, streamId }));++ if (!ingress()) { dropped('disconnectUser', 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }return moderate('disconnect', { user_id: userId || undefined, ip: ip || undefined, stream_id: streamId || undefined });}++const disconnectUser = disconnect;/** legacy: what to run when Live is the chat authority (cache hints had no single local call). */function invalidate(hint, legacy) {@@@ -100,7 +111,7 @@ const MIRROR_WRITES = new Set([function mirror(fn, ...args) {try {if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');-- return require('../db/database')[fn](...args);++ return db[fn](...args);} catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }}@@@ -108,4 +119,215 @@ function after(value, fn) {return value && typeof value.then === 'function' ? value.then(fn) : fn(value);}--module.exports = { ingress, message, event, moderate, logModeration, disconnect, invalidate, mirror, after, client, MIRROR_WRITES };++// ── The pushes modules still make on the chat server (names kept from ChatServer) ─────────────++function broadcastToStream(streamId, frame) { return event({ kind: 'stream', id: streamId }, frame); }++function broadcastToChannelRoom(channelUserId, streamId, frame) { return event({ kind: 'channel', id: channelUserId }, frame); }++function broadcastGlobal(frame) { return event({ kind: 'global' }, frame); }++function broadcastAll(frame) { return event({ kind: 'all' }, frame); }++function sendDm(userId, frame) { return event({ kind: 'user', id: userId }, frame); }++function sendUserUpdate(userId, u) {++ const id = Number(userId) || undefined;++ const userData = u ? { id, username: u.username, display_name: u.display_name || null, role: u.role || null, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : undefined;++ return invalidate({ user: id, user_data: userData });++}++// No ingress target — the old ChatServer forwarded these to Live's own other rooms/slots; Chat fans a++// channel event out to the channel's rooms itself, and speaks/sounds from the message's own fields.++function forwardToGlobal() { dropped('forwardToGlobal'); }++function forwardToStreamerRooms() { dropped('forwardToStreamerRooms'); }++function triggerChannelSound() { dropped('triggerChannelSound'); }++function synthesizeAndBroadcastTTS() { dropped('synthesizeAndBroadcastTTS'); return Promise.resolve(); }++++// ── Presence: Chat's snapshot, polled (the synchronous reads health, diagnostics and moderation use) ──++const OBSERVED_DB = {++ approveIp: (a) => ({ approvals: Number(a[0]) || undefined }),++ revokeIpApproval: (a) => ({ approvals: Number(a[0]) || undefined }),++ forgiveBan: () => ({ bans: true }),++};++const PRESENCE_MS = 3000;++const slowModeByStream = new Map();++let _presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };++let _presenceTimer = null;++let _dbObserved = false;++++// Live-owned data Chat caches, written by Live's own routes (IP approvals, bans): after the write Chat is++// told to reload it instead of waiting for its cache to expire.++function _observeDb() {++ for (const [fn, hint] of Object.entries(OBSERVED_DB)) {++ const orig = db[fn];++ if (typeof orig !== 'function' || orig._chatObserved) continue;++ const observed = (...args) => {++ const result = orig(...args);++ try { invalidate(hint(args)); } catch { /* non-critical */ }++ return result;++ };++ observed._chatObserved = true;++ db[fn] = observed;++ }++}++++async function _pollPresence() {++ try {++ const p = await client.presence();++ if (!p) return;++ _presence = p;++ slowModeByStream.clear();++ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);++ } catch { /* keep the last snapshot */ }++}++++/** Start the presence poll (and the cache-hint observers). Called once at startup; no-op outside chat mode. */++function init() {++ if (!ingress()) return null;++ if (!_dbObserved) { _dbObserved = true; _observeDb(); }++ if (_presenceTimer) return null;++ _pollPresence(); // floating-ok: _pollPresence catches and keeps the last snapshot++ _presenceTimer = setInterval(() => _pollPresence(), PRESENCE_MS);++ if (_presenceTimer.unref) _presenceTimer.unref();++ console.log('[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat; Live delivers through its ingress');++ return null;++}++++function close() {++ if (_presenceTimer) clearInterval(_presenceTimer);++ _presenceTimer = null;++}++++function getTotalConnections() { return Number(_presence.total) || 0; }++function getStreamViewerCount(streamId) { return Number((_presence.streams || {})[streamId]) || 0; }++function getConnectedUserIp(userId) {++ const hit = (_presence.users || []).find((u) => u.user_id === userId);++ return hit ? hit.ip : null;++}++function findClientByAnonId(anonId, streamId) {++ // The socket's stream must equal streamId; with no streamId given (a moderator looking an anon up by id alone),++ // any of that anon's sockets answers.++ const hit = (_presence.anons || []).find((a) => a.anon_id === anonId && (streamId === undefined || (a.stream_id ?? null) === streamId));++ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;++}++++// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon, call-server) ──++const _openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';++const anonMap = new Map(); // IP → number++const pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)++let nextAnonId = 1;++let _anonDbLoaded = false;++++function normalizeIp(ip) {++ let normalized = String(ip || 'unknown').trim();++ if (!normalized) normalized = 'unknown';++ if (normalized === '::1') return '127.0.0.1';++ if (normalized.startsWith('::ffff:')) return normalized.slice(7);++ return normalized;++}++++/**++ * Extract the real client IP from Express/WS request.++ * Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),++ * then X-Forwarded-For first entry, then socket remote address.++ */++function getClientIp(req) {++ const raw = req.headers?.['cf-connecting-ip']++ || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()++ || req.socket?.remoteAddress++ || req.connection?.remoteAddress++ || 'unknown';++ return normalizeIp(raw);++}++++/** Warm the in-memory anonMap from DB on first use, so anon numbers survive server restarts. */++function _loadAnonMappings() {++ if (_anonDbLoaded) return;++ _anonDbLoaded = true;++ try {++ const { maxNum, mappings } = db.loadAnonMappings();++ for (const [ip, num] of mappings) anonMap.set(ip, num);++ nextAnonId = maxNum + 1;++ if (mappings.size > 0) {++ console.log(`[Chat] Loaded ${mappings.size} persistent anon mappings (next: anon${nextAnonId})`);++ }++ } catch (e) {++ console.warn('[Chat] Failed to load anon mappings from DB:', e.message);++ }++}++++/** Resolve anon number from openvibe.network unified API. Returns a Promise<number>; falls back to local DB. */++async function _resolveUnifiedAnonNum(ip) {++ if (anonMap.has(ip)) return anonMap.get(ip);++ if (pendingResolves.has(ip)) return pendingResolves.get(ip);++++ const promise = (async () => {++ try {++ const auth = await require('../net/network-principal').headersFor('/internal/resolve-anon');++ const res = await fetch(`${_openvibeToolsUrl}/internal/resolve-anon`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...auth },++ body: JSON.stringify({ ip }),++ });++ if (res.ok) {++ const data = await res.json();++ const num = data.anon_number;++ anonMap.set(ip, num);++ if (num >= nextAnonId) nextAnonId = num + 1;++ try { db.getOrCreateAnonNum(ip); } catch { /* ok */ } // warmup cache++ return num;++ }++ throw new Error(`HTTP ${res.status}`);++ } catch (e) {++ console.warn(`[Chat] Unified anon resolve failed for ${ip}, falling back to local:`, e.message);++ try {++ const num = db.getOrCreateAnonNum(ip);++ anonMap.set(ip, num);++ if (num >= nextAnonId) nextAnonId = num + 1;++ return num;++ } catch {++ const num = nextAnonId++;++ anonMap.set(ip, num);++ return num;++ }++ } finally {++ pendingResolves.delete(ip);++ }++ })();++++ pendingResolves.set(ip, promise);++ return promise;++}++++function getAnonIdForIp(ip) {++ _loadAnonMappings();++ const anonKey = normalizeIp(ip);++ if (anonMap.has(anonKey)) return `anon${anonMap.get(anonKey)}`;++ // Synchronous fallback for immediate use — kick off the unified resolve in the background.++ _resolveUnifiedAnonNum(anonKey).catch(() => {});++ try {++ const num = db.getOrCreateAnonNum(anonKey);++ anonMap.set(anonKey, num);++ if (num >= nextAnonId) nextAnonId = num + 1;++ return `anon${num}`;++ } catch {++ const num = nextAnonId++;++ anonMap.set(anonKey, num);++ return `anon${num}`;++ }++}++++function getAnonIdForConnection(ip, streamId = null) {++ const key = normalizeIp(ip);++ const hit = (_presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));++ return hit ? hit.anon_id : getAnonIdForIp(key);++}++++/** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */++async function resolveAnon(ip) {++ const key = normalizeIp(ip);++ const num = await _resolveUnifiedAnonNum(key);++ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };++}++++module.exports = {++ ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, mirror, after, client, MIRROR_WRITES,++ broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,++ forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,++ init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,++ normalizeIp, getClientIp, getAnonIdForIp, getAnonIdForConnection, resolveAnon,++};ddeleted file mode 100644@@@ -1,2648 +0,0 @@--/**-- * OpenVibe.Live — WebSocket Chat Server-- *-- * Features:-- * - Anonymous chat with sequential numbering (anon12345)-- * - Global chat + per-stream chat-- * - Word filtering (safe/unsafe mode)-- * - Anti-VPN approval queue-- * - Streamer moderation (ban, timeout, delete)-- * - Chat commands (/help, /tts, /color, etc.)-- * - Rate limiting-- */--const WebSocket = require('ws');--const fs = require('fs');--const path = require('path');--const { v4: uuidv4 } = require('uuid');--const db = require('../db/database');--const { extractWsToken, authenticateWs } = require('../auth/auth');--const permissions = require('../auth/permissions');--const moderation = require('./moderation-client');--const wordFilter = require('./word-filter');--const cosmetics = require('../monetization/cosmetics');--const ttsEngine = require('./tts-engine');--const soundboard = require('./soundboard-service');--const dm = require('./dm');--const ipUtils = require('../admin/ip-utils');--// Concurrent chat sockets per address. Generous: one person has several tabs, a school or--// carrier NAT puts many people behind one address; this only stops one host opening thousands.--const MAX_CHAT_SOCKETS_PER_IP = 48;----const DEBUG_DM_DELIVERY = process.env.DEBUG_DM_DELIVERY === '1';----const WS_HEARTBEAT_MS = 30000;--const CHAT_AUTO_DELETE_SWEEP_MS = 30000;--const MIN_CHAT_AUTO_DELETE_MINUTES = 3;--const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;--const MAX_SEND_BACKPRESSURE = 256 * 1024;--const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;--const SOUNDBOARD_RATE_LIMIT_MS = 8000;--const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;--const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;--const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';--const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';--const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';--// Built-in slur categories and normalization are defined in moderation-utils.js--// (single source of truth — browser-side chat.js mirrors the same patterns).--const {-- CORE_SLUR_CATEGORIES,-- normalizeSlurText: _modNormalizeSlurText,-- normalizeSlurPatternText: _modNormalizeSlurPatternText,-- containsCoreSlur: _modContainsCoreSlur,-- containsRegexSlur: _modContainsRegexSlur,-- containsConfiguredSlur: _modContainsConfiguredSlur,-- compileRegexList: _modCompileRegexList,--} = require('./moderation-utils');----class ChatServer {-- constructor() {-- this.wss = null;-- /** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */-- this.clients = new Map();-- /** @type {Map<string, number>} ip → open sockets */-- this._ipSockets = new Map();-- /** @type {Map<string, number>} IP → unified anon number (warm cache, backed by openvibe.network) */-- this.anonMap = new Map();-- this.nextAnonId = 1;-- this._anonDbLoaded = false;-- /** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */-- this.rateLimits = new Map();-- this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second-- /** @type {Map<number, number>} streamId → slow mode ms (0 = off, default rate limit applies) */-- this.slowModeByStream = new Map();-- this.heartbeatInterval = null;-- /** @type {Map<number, number>} streamId → current TTS queue size */-- this.ttsQueueSize = new Map();-- /** @type {Map<string, number>} `${streamId}:${userId}` → user's TTS queue count */-- this.ttsUserCounts = new Map();-- /** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */-- this.soundboardRateLimits = new Map();-- /** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */-- this.soundboardStreamLimits = new Map();-- this._autoDeleteSweepInterval = null;---- // ── Unified anon resolution via openvibe.network internal API ──-- this._openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';-- /** @type {Map<string, Promise<number>>} IP → pending resolve promise (dedup concurrent) */-- this._pendingResolves = new Map();-- }---- normalizeIp(ip) {-- let normalized = String(ip || 'unknown').trim();-- if (!normalized) normalized = 'unknown';-- if (normalized === '::1') return '127.0.0.1';-- if (normalized.startsWith('::ffff:')) return normalized.slice(7);-- return normalized;-- }---- /**-- * Extract the real client IP from Express/WS request.-- * Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),-- * then X-Forwarded-For first entry, then socket remote address.-- */-- /** Admins (the site owner) pass IP / network bans — they may share a home network with a banned person. */-- /**-- * Is this viewer new to OpenVibe.Live? True for an account under a day old — unless the same-- * address has had an anon identity for longer — and for an anon first seen under a day ago.-- * Drives the default of the viewer-side "Friendly global chat" setting only.-- */-- _isNewcomer(client) {-- const DAY = 24 * 3600 * 1000;-- const age = (ts) => { if (!ts) return null; const t = Date.parse(String(ts).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(ts)) ? '' : 'Z')); return Number.isFinite(t) ? Date.now() - t : null; };-- let anonAge = null;-- try { anonAge = age(db.getAnonFirstSeen(this.normalizeIp(client.ip))); } catch { /* */ }-- if (client.user) {-- const userAge = age(client.user.created_at);-- if (userAge == null || userAge >= DAY) return false;-- return !(anonAge != null && anonAge >= DAY);-- }-- return anonAge == null ? false : anonAge < DAY;-- }---- _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && permissions.can(client.user, 'staff.limits.exempt')); }---- getClientIp(req) {-- const raw = req.headers?.['cf-connecting-ip']-- || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()-- || req.socket?.remoteAddress-- || req.connection?.remoteAddress-- || 'unknown';-- return this.normalizeIp(raw);-- }---- /**-- * Warm the in-memory anonMap from DB on first use.-- * This ensures anon numbers survive server restarts.-- */-- _loadAnonMappings() {-- if (this._anonDbLoaded) return;-- this._anonDbLoaded = true;-- try {-- const { maxNum, mappings } = db.loadAnonMappings();-- for (const [ip, num] of mappings) {-- this.anonMap.set(ip, num);-- }-- this.nextAnonId = maxNum + 1;-- if (mappings.size > 0) {-- console.log(`[Chat] Loaded ${mappings.size} persistent anon mappings (next: anon${this.nextAnonId})`);-- }-- } catch (e) {-- console.warn('[Chat] Failed to load anon mappings from DB:', e.message);-- }-- }---- /**-- * Resolve anon number from openvibe.network unified API.-- * Returns a Promise<number>. Caches in memory and falls back to local DB.-- */-- async _resolveUnifiedAnonNum(ip) {-- // Already cached-- if (this.anonMap.has(ip)) return this.anonMap.get(ip);---- // Dedup concurrent resolves for the same IP-- if (this._pendingResolves.has(ip)) return this._pendingResolves.get(ip);---- const promise = (async () => {-- try {-- const auth = await require('../net/network-principal').headersFor('/internal/resolve-anon');-- const res = await fetch(`${this._openvibeToolsUrl}/internal/resolve-anon`, {-- method: 'POST',-- headers: {-- 'Content-Type': 'application/json',-- ...auth,-- },-- body: JSON.stringify({ ip }),-- });-- if (res.ok) {-- const data = await res.json();-- const num = data.anon_number;-- this.anonMap.set(ip, num);-- if (num >= this.nextAnonId) this.nextAnonId = num + 1;-- // Also sync to local DB for warmup cache-- try { db.getOrCreateAnonNum(ip); } catch { /* ok */ }-- return num;-- }-- throw new Error(`HTTP ${res.status}`);-- } catch (e) {-- console.warn(`[Chat] Unified anon resolve failed for ${ip}, falling back to local:`, e.message);-- // Fallback to local DB-- try {-- const num = db.getOrCreateAnonNum(ip);-- this.anonMap.set(ip, num);-- if (num >= this.nextAnonId) this.nextAnonId = num + 1;-- return num;-- } catch {-- const num = this.nextAnonId++;-- this.anonMap.set(ip, num);-- return num;-- }-- } finally {-- this._pendingResolves.delete(ip);-- }-- })();---- this._pendingResolves.set(ip, promise);-- return promise;-- }---- getAnonIdForIp(ip) {-- this._loadAnonMappings();-- const anonKey = this.normalizeIp(ip);-- if (this.anonMap.has(anonKey)) {-- return `anon${this.anonMap.get(anonKey)}`;-- }-- // Synchronous fallback for immediate use — kick off async resolve in background-- this._resolveUnifiedAnonNum(anonKey).catch(() => {});-- // Use local DB for synchronous path-- try {-- const num = db.getOrCreateAnonNum(anonKey);-- this.anonMap.set(anonKey, num);-- if (num >= this.nextAnonId) this.nextAnonId = num + 1;-- return `anon${num}`;-- } catch (e) {-- // Fallback: use in-memory only-- const num = this.nextAnonId++;-- this.anonMap.set(anonKey, num);-- return `anon${num}`;-- }-- }---- getAnonIdForConnection(ip, streamId = null) {-- const anonKey = this.normalizeIp(ip);-- for (const [, info] of this.clients) {-- if (info.ip !== anonKey || !info.anonId) continue;-- if (streamId == null || info.streamId === streamId) {-- return info.anonId;-- }-- }-- return this.getAnonIdForIp(anonKey);-- }---- /**-- * Attach to an existing HTTP server for WebSocket upgrade-- */-- init(server) {-- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });---- // Word filter-- wordFilter.load();---- if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);-- this.heartbeatInterval = setInterval(() => {-- if (!this.wss) return;---- const now = Date.now();-- for (const [ip, lastSeen] of this.rateLimits.entries()) {-- if ((now - lastSeen) > RATE_LIMIT_CACHE_TTL_MS) {-- this.rateLimits.delete(ip);-- }-- }---- this.wss.clients.forEach((ws) => {-- if (ws.isAlive === false) {-- try { ws.terminate(); } catch {}-- return;-- }-- ws.isAlive = false;-- try { ws.ping(); } catch {}-- });-- }, WS_HEARTBEAT_MS);---- // ── Viewer snapshot recording (every 60s) ────────────-- if (this._snapshotInterval) clearInterval(this._snapshotInterval);-- this._snapshotInterval = setInterval(() => {-- this._recordViewerSnapshots();-- }, 60_000);---- if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);-- this._autoDeleteSweepInterval = setInterval(() => {-- this._sweepExpiredChatMessages();-- }, CHAT_AUTO_DELETE_SWEEP_MS);-- this._sweepExpiredChatMessages();---- this.wss.on('connection', (ws, req) => {-- this.handleConnection(ws, req);-- });---- console.log('[Chat] WebSocket chat server initialized');-- return this.wss;-- }---- /**-- * Handle WebSocket upgrade for chat connections-- */-- handleUpgrade(req, socket, head) {-- if (req.url.startsWith('/ws/chat')) {-- this.wss.handleUpgrade(req, socket, head, (ws) => {-- this.wss.emit('connection', ws, req);-- });-- return true;-- }-- return false;-- }---- /**-- * Handle a new chat connection-- */-- handleConnection(ws, req) {-- const ip = this.getClientIp(req);---- // Diagnostic: log IP resolution chain for first few connections-- if (this.anonMap.size < 20) {-- console.log('[Chat] IP resolution — cf-connecting-ip:', req.headers?.['cf-connecting-ip'] || '(none)',-- '| x-forwarded-for:', req.headers?.['x-forwarded-for'] || '(none)',-- '| x-real-ip:', req.headers?.['x-real-ip'] || '(none)',-- '| socket:', req.socket?.remoteAddress || '(none)',-- '| resolved:', ip);-- }---- const urlParams = new URL(req.url, 'http://localhost').searchParams;-- const token = extractWsToken(req);-- const streamId = parseInt(urlParams.get('stream')) || null;---- ws.isAlive = true;-- ws.on('pong', () => {-- ws.isAlive = true;-- });-- try { ws._socket?.setNoDelay(true); } catch {}---- // Authenticate (optional — anon if no token)-- const user = authenticateWs(token);---- const perIp = this._ipSockets.get(ip) || 0;-- if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {-- ws.close(4029, 'Too many connections');-- return;-- }-- this._ipSockets.set(ip, perIp + 1);-- let released = false;-- const release = () => {-- if (released) return;-- released = true;-- const n = (this._ipSockets.get(ip) || 1) - 1;-- if (n > 0) this._ipSockets.set(ip, n); else this._ipSockets.delete(ip);-- };---- // Generate or reuse anon ID for this IP-- const anonId = user ? null : this.getAnonIdForConnection(ip, streamId);---- const clientInfo = {-- user,-- anonId,-- streamId,-- ip,-- joinedAt: Date.now(),-- };-- this.clients.set(ws, clientInfo);---- // Log IP for tracking-- try {-- const geo = ipUtils.enrichIp(ip);-- db.logIp({ userId: user?.id, anonId, ip, action: 'chat', geo });-- } catch (e) { /* non-critical */ }---- // (No verbose welcome message — the client shows a concise "Connected to-- // chat" / "Chatting as X" instead.)---- // Send user count + users list update-- this.broadcastUserCount(streamId);-- this.broadcastUsersList(streamId);---- // ── Message handler ──────────────────────────────────-- ws.on('message', (data) => {-- try {-- const msg = JSON.parse(data.toString());-- this.handleMessage(ws, msg);-- } catch (err) {-- console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);-- }-- });---- ws.on('close', () => {-- release();-- this.clients.delete(ws);-- this.broadcastUserCount(streamId);-- this.broadcastUsersList(streamId);-- });---- ws.on('error', (err) => {-- console.warn('[Chat] WebSocket error for', ws._clientIp || 'unknown', ':', err.message);-- release();-- this.clients.delete(ws);-- });-- }---- /**-- * Handle incoming chat message-- */-- handleMessage(ws, msg) {-- const client = this.clients.get(ws);-- if (!client) return;---- // Rate limiting (only for chat messages, not join/leave)-- if (msg.type === 'chat') {-- const now = Date.now();-- const rateKey = `${client.ip}:${client.streamId || 'global'}`;-- const lastMsg = this.rateLimits.get(rateKey) || 0;-- const streamSlowMs = this.slowModeByStream.get(client.streamId) || 0;-- const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);-- if (now - lastMsg < effectiveLimit) {-- this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });-- return;-- }-- this.rateLimits.set(rateKey, now);-- }---- switch (msg.type) {-- case 'chat':-- this.handleChatMessage(ws, client, msg);-- break;-- case 'self-delete-history':-- this.handleSelfDeleteHistory(ws, client);-- break;-- case 'join':-- case 'join_stream': {-- // (Re-)authenticate if a token is provided-- if (msg.token) {-- const user = authenticateWs(msg.token);-- if (user) {-- if (!client.user || client.user.id === user.id) {-- client.user = user;-- client.anonId = null; // no longer anonymous-- } else {-- console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);-- }-- }-- }-- const oldStream = client.streamId;-- client.streamId = parseInt(msg.streamId || msg.stream_id) || null;-- // Channel room: the streamer's stable user id. Lets a viewer stay in-- // the SAME chat room across live-slot switches AND while the streamer-- // is offline. Prefer an explicit channelUserId from the client; else-- // derive it from the live stream's owner.-- let channelUserId = parseInt(msg.channelUserId || msg.channel_user_id) || null;-- // With a stream, the room is that stream's owner — whatever the client says. Taking the-- // client's value let a viewer join stream A's session while posting into channel B's-- // room, where B's bans and chat rules (checked against the stream) never applied.-- if (client.streamId) {-- channelUserId = null;-- try { const s = db.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }-- }-- client.channelUserId = channelUserId;-- client._modStream = null;-- // Update viewer counts for old and new streams-- if (oldStream !== client.streamId) {-- if (oldStream) this.broadcastUserCount(oldStream);-- this.broadcastUserCount(client.streamId);-- }-- // A deploy notice from this boot reaches late joiners too (once per socket; see deploy-notice.js).-- setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);-- // Send identity confirmation so the client knows who it is-- const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;-- const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;-- const streamSettings = this._getChannelChatSettings(client.streamId);-- this.sendTo(ws, {-- type: 'auth',-- authenticated: !!client.user,-- newcomer: this._isNewcomer(client),-- username: displayName,-- core_username: client.user?.username || null,-- role: client.user ? client.user.role : 'anon',-- user_id: client.user?.id || null,-- slowmode_seconds: streamSlowSec,-- allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,-- allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,-- gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,-- custom_emotes_enabled: !client.streamId || streamSettings.custom_emotes_enabled !== 0,-- custom_sounds_enabled: !client.streamId || streamSettings.custom_sounds_enabled !== 0,-- uploads_mods_only: !!(client.streamId && streamSettings.uploads_mods_only),-- max_sound_seconds: client.streamId ? (streamSettings.max_sound_seconds || 10) : 10,-- emote_scale: client.streamId ? (streamSettings.emote_scale || 100) : 100,-- soundboard_enabled: !client.streamId || streamSettings.soundboard_enabled !== 0,-- soundboard_allow_pitch: !client.streamId || streamSettings.soundboard_allow_pitch !== 0,-- soundboard_allow_speed: !client.streamId || streamSettings.soundboard_allow_speed !== 0,-- slur_filter_enabled: !!(client.streamId && streamSettings.slur_filter_enabled),-- slur_filter_use_builtin: streamSettings.slur_filter_use_builtin !== 0,-- slur_filter_disabled_categories: (() => { try { return JSON.parse(streamSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })(),-- slur_filter_terms: this._parseSlurFilterTerms(streamSettings.slur_filter_terms),-- slur_filter_regexes: this._parseRegexLines(streamSettings.slur_filter_regexes),-- slur_filter_nudge_message: String(streamSettings.slur_filter_nudge_message || ''),-- min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,-- // Language the channel lives in — the client shows "auto-translated for the-- // streamer" when it isn't English (see server/i18n/translate.js).-- channel_language: this._channelLanguage(client.channelUserId),-- });-- break;-- }-- case 'leave_stream':-- client.streamId = null;-- break;-- case 'get-users':-- this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });-- break;-- default:-- break;-- }-- }---- /** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */-- _moderationStreamFor(client) {-- if (!client || !client.channelUserId) return null;-- const now = Date.now();-- if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;-- let id = null;-- try { id = (db.get('SELECT id FROM streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [client.channelUserId]) || {}).id || null; } catch { id = null; }-- client._modStream = { id, at: now };-- return id;-- }---- handleSelfDeleteHistory(ws, client) {-- if (!client) return;---- const canBypass = client.streamId ? permissions.canModerateStreamSync(client.user, client.streamId) : false;-- const chatSettings = this._getChannelChatSettings(client.streamId);-- if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {-- this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });-- return;-- }---- let ids = [];-- if (client.user?.id) {-- ids = db.deleteUserChatMessages(client.user.id, {-- streamId: client.streamId || null,-- deletedBy: client.user.id,-- });-- } else if (client.anonId) {-- ids = db.deleteAnonChatMessages(client.anonId, {-- streamId: client.streamId || null,-- deletedBy: null,-- });-- } else {-- this.sendTo(ws, { type: 'error', message: 'Join chat first before deleting history.' });-- return;-- }---- this._broadcastDeletedMessages(client.streamId || null, ids);---- try {-- db.logModerationAction({-- scope_type: client.streamId ? 'stream' : 'site',-- scope_id: client.streamId || undefined,-- actor_user_id: client.user?.id || undefined,-- action_type: 'self_message_delete_all',-- details: {-- count: ids.length,-- stream_id: client.streamId || null,-- anon_id: client.user ? null : client.anonId,-- },-- });-- } catch { /* non-critical */ }---- this.sendTo(ws, {-- type: 'self-delete-result',-- count: ids.length,-- scope: client.streamId ? 'stream' : 'global',-- });-- }---- /**-- * Build a deduplicated list of users in a given stream (or global if null).-- * Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }-- */-- getUserList(streamId) {-- const seen = new Set();-- const logged = [];-- let anonCount = 0;-- for (const [, c] of this.clients) {-- if (c.streamId !== streamId) continue;-- if (c.user) {-- if (seen.has(c.user.id)) continue;-- seen.add(c.user.id);-- logged.push({-- username: c.user.username,-- display_name: c.user.display_name || c.user.username,-- avatar_url: c.user.avatar_url || null,-- role: c.user.role,-- });-- } else if (c.anonId) {-- if (seen.has(c.anonId)) continue;-- seen.add(c.anonId);-- anonCount++;-- }-- }-- // Sort: admins first, then mods, then alphabetical-- const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };-- logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));-- return { logged, anonCount };-- }---- _parseSlurFilterTerms(rawTerms) {-- return String(rawTerms || '')-- .split(/[\n,]/)-- .map((t) => t.trim().toLowerCase())-- .filter(Boolean)-- .slice(0, 200);-- }---- _parseRegexLines(rawRegexes) {-- return String(rawRegexes || '')-- .split(/\r?\n/)-- .map((line) => line.trim())-- .filter(Boolean)-- .slice(0, 300);-- }---- // Normalization and matching are delegated to moderation-utils.js (shared module).-- // These thin wrappers preserve the existing call sites inside this class.-- _normalizeSlurText(input) { return _modNormalizeSlurText(input); }-- _normalizeSlurPatternText(input) { return _modNormalizeSlurPatternText(input); }-- _containsCoreSlur(text, disabledCategories = []) { return _modContainsCoreSlur(text, disabledCategories); }-- _containsRegexSlur(text, regexLines) { return _modContainsRegexSlur(text, regexLines); }-- _containsConfiguredSlur(text, terms) { return _modContainsConfiguredSlur(text, terms); }-- _compileRegexList(patternStrings, opts) { return _modCompileRegexList(patternStrings, opts); }---- /**-- * Handle a chat message-- */-- handleChatMessage(ws, client, msg) {-- let text = (msg.message || '').trim();-- // Absolute upper bound (DoS guard) = the highest value any channel/admin can configure-- // (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at-- // the `max_message_length` check — this must never be lower than that or it silently-- // swallows long messages the channel actually allows.-- if (!text || text.length > 6000) return;---- // ── Ban check ────────────────────────────────────────-- // This has to come before the command dispatch below, not after it. Both command paths-- // return early, so a banned viewer could still run every "!" utility command — queueing-- // and skipping media on the stream they were banned from — and every "/" command,-- // including /me, which puts their text back in the chat they are banned from. A ban means-- // no input of any kind.-- // Offline channel chat has no stream of its own; bans and chat rules come from the channel's-- // most recent stream, so a ban does not stop at the end of a broadcast.-- const modStreamId = client.streamId || this._moderationStreamFor(client);-- if (client.user && db.isUserBanned(client.user.id, modStreamId)) {-- this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });-- return;-- }-- if (!this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, modStreamId)) {-- this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });-- return;-- }---- // ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──-- if (text.startsWith('!')) {-- this.handleBangCommand(ws, client, text);-- return;-- }---- // ── Chat commands ────────────────────────────────────-- if (text.startsWith('/')) {-- this.handleCommand(ws, client, text);-- return;-- }---- // ── IP Approval Mode (Anti-VPN) ─────────────────────-- if (client.streamId && client.ip) {-- try {-- const stream = db.getStreamById(client.streamId);-- const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);-- if (channel) {-- const settings = moderation.settingsSync(channel.id);-- if (settings?.ip_approval_mode) {-- const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);-- const isOwner = client.user && stream && stream.user_id === client.user.id;-- if (!isStaffBypass && !isOwner) {-- if (!db.isIpApproved(channel.id, client.ip)) {-- // Auto-approve IPs that have existing non-deleted chat messages in this channel's streams-- const existing = db.get(-- `SELECT 1 FROM chat_messages cm-- JOIN streams s ON cm.stream_id = s.id-- WHERE s.channel_id = ? AND cm.is_deleted = 0-- AND (cm.user_id = ? OR cm.anon_id = ?)-- LIMIT 1`,-- [channel.id, client.user?.id || -1, client.anonId || '']-- );-- if (existing) {-- // This user has chatted before — auto-approve their IP-- db.approveIp(channel.id, client.ip, null, 'auto_existing');-- } else {-- // Hold the message for streamer approval-- const username = client.user ? client.user.display_name : client.anonId;-- db.holdMessageForApproval({-- channelId: channel.id,-- streamId: client.streamId,-- ip: client.ip,-- userId: client.user?.id || null,-- anonId: client.anonId || null,-- username,-- message: text,-- });-- this.sendTo(ws, {-- type: 'system',-- message: 'This channel has IP approval mode enabled. Your message is being held for review by the streamer.',-- });-- // Notify the streamer that a new IP needs approval-- this._notifyStreamerPendingIp(client.streamId, stream.user_id, username, client.ip);-- return;-- }-- }-- }-- }-- }-- } catch { /* non-critical — don't block chat for IP approval errors */ }-- }---- // ── Channel moderation settings ──────────────────────-- if (modStreamId) {-- const chatSettings = this._getChannelChatSettings(modStreamId);-- const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);-- const canModerateThisStream = permissions.canModerateStreamSync(client.user, modStreamId);---- // Max message length-- const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));-- if (text.length > maxLen) {-- this.sendTo(ws, { type: 'system', message: `Message too long. Max ${maxLen} characters.` });-- return;-- }---- // Anonymous not allowed-- if (!client.user && !chatSettings.allow_anonymous) {-- this.sendTo(ws, { type: 'system', message: 'This channel requires a logged-in account to chat.' });-- return;-- }---- // Links disabled — exempt [gif:url] tags (validated separately)-- if (chatSettings.links_allowed === 0 && !isStaff) {-- const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');-- if (/(https?:\/\/|www\.)/i.test(textWithoutGifs)) {-- this.sendTo(ws, { type: 'system', message: 'Links are disabled in this channel chat.' });-- return;-- }-- }---- // Validate [gif:url] — only allow trusted domains-- const gifTagMatch = text.match(/\[gif:(https?:\/\/[^\]]+)\]/i);-- if (gifTagMatch) {-- const ALLOWED_GIF_DOMAINS = ['tenor.com', 'media.tenor.com', 'media1.tenor.com', 'c.tenor.com', 'giphy.com', 'media.giphy.com', 'media0.giphy.com', 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com', 'i.giphy.com'];-- try {-- const gifUrl = new URL(gifTagMatch[1]);-- if (!ALLOWED_GIF_DOMAINS.includes(gifUrl.hostname)) {-- this.sendTo(ws, { type: 'system', message: 'Only Tenor and Giphy GIFs are allowed.' });-- return;-- }-- } catch {-- this.sendTo(ws, { type: 'system', message: 'Invalid GIF URL.' });-- return;-- }-- }---- // Followers only-- if (chatSettings.followers_only && client.user && !isStaff) {-- const stream = db.getStreamById(modStreamId);-- if (stream && stream.user_id !== client.user.id && !db.isFollowing(client.user.id, stream.user_id)) {-- this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });-- return;-- }-- }---- // Account age gate-- if (chatSettings.account_age_gate_hours && client.user && !isStaff) {-- const ageMs = Date.now() - new Date(client.user.created_at).getTime();-- if (ageMs < Number(chatSettings.account_age_gate_hours) * 3600000) {-- this.sendTo(ws, { type: 'system', message: `This chat requires accounts older than ${chatSettings.account_age_gate_hours} hour(s).` });-- return;-- }-- }---- // Optional per-streamer anti-slur filter-- if (chatSettings.slur_filter_enabled && !isStaff && !canModerateThisStream) {-- const blockedTerms = this._parseSlurFilterTerms(chatSettings.slur_filter_terms);-- const configuredRegexLines = this._parseRegexLines(chatSettings.slur_filter_regexes);-- const hitConfigured = blockedTerms.length && this._containsConfiguredSlur(text, blockedTerms);-- const hitCore = chatSettings.slur_filter_use_builtin !== 0 && this._containsCoreSlur(text, (() => { try { return JSON.parse(chatSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })());-- const hitRegex = configuredRegexLines.length && this._containsRegexSlur(text, configuredRegexLines);-- if (hitConfigured || hitCore || hitRegex) {-- this.sendTo(ws, {-- type: 'slur-blocked',-- message: String(chatSettings.slur_filter_nudge_message || '').trim() || DEFAULT_SLUR_NUDGE,-- streamer_enabled: true,-- });-- return;-- }-- }---- }---- const username = client.user ? client.user.display_name : client.anonId;-- const coreUsername = client.user ? client.user.username : null;-- const role = client.user ? client.user.role : 'anon';---- // Voice channel tagging — clients can tag messages with the voice channel they're in-- const voiceChannelId = (typeof msg.voiceChannelId === 'string' && msg.voiceChannelId) ? msg.voiceChannelId : null;---- // Reply-to support — client sends reply_to_id, we look up the parent message-- const replyToId = msg.reply_to_id ? parseInt(msg.reply_to_id) : null;-- let replyTo = null;-- if (replyToId) {-- try {-- const parent = db.getChatMessageById(replyToId);-- const parentStillVisible = parent && !parent.is_deleted-- && (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());-- if (parentStillVisible) {-- replyTo = {-- id: parent.id,-- username: parent.username,-- user_id: parent.user_id,-- message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,-- };-- }-- } catch { /* non-critical */ }-- }---- const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);-- const allowViewerAutoDelete = !client.streamId-- || this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0-- || permissions.canModerateStreamSync(client.user, client.streamId);-- const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)-- && requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES-- && allowViewerAutoDelete-- ? new Date(Date.now() + Math.min(MAX_CHAT_AUTO_DELETE_MINUTES, requestedAutoDeleteMinutes) * 60 * 1000).toISOString()-- : null;---- const chatMsg = {-- type: 'chat',-- username,-- core_username: coreUsername,-- user_id: client.user?.id || null,-- anon_id: client.anonId,-- role,-- message: text,-- stream_id: client.streamId,-- channel_user_id: client.channelUserId || null,-- is_global: !client.streamId && !client.channelUserId,-- avatar_url: client.user?.avatar_url || null,-- profile_color: client.user?.profile_color || '#999',-- filtered: false,-- timestamp: new Date().toISOString(),-- auto_delete_at: autoDeleteAt,-- };---- // Preserve voice channel tag so clients can filter voice-call messages-- if (voiceChannelId) chatMsg.voiceChannelId = voiceChannelId;---- // Attach cosmetic data for chat rendering-- if (client.user?.id) {-- try {-- const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);-- if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;-- if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;-- if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;-- if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;-- } catch { /* non-critical */ }---- // Attach equipped tag for chat rendering-- try {-- const tags = require('./tags');-- const tagProfile = tags.getTagProfile(client.user.id);-- if (tagProfile) chatMsg.tag = tagProfile;-- } catch { /* non-critical */ }-- }---- // Save to database-- try {-- const result = db.saveChatMessage({-- stream_id: client.streamId || null,-- channel_user_id: client.channelUserId || null,-- user_id: client.user?.id,-- anon_id: client.anonId,-- username,-- message: text,-- message_type: 'chat',-- is_global: !client.streamId && !client.channelUserId,-- reply_to_id: replyToId,-- auto_delete_at: autoDeleteAt,-- });-- if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);-- } catch { /* non-critical */ }---- // Attach reply context to broadcast-- if (replyTo) chatMsg.reply_to = replyTo;---- // Award OpenCoins for chatting (logged-in users only)-- if (client.user?.id && client.streamId) {-- try {-- const openvibeCoins = require('../monetization/opencoins');-- const coinResult = openvibeCoins.awardChat(client.user.id, client.streamId);-- if (coinResult) {-- this.sendTo(ws, {-- type: 'coin_earned',-- coins: coinResult.coins,-- total: coinResult.total,-- streamerId: coinResult.streamerId,-- reason: 'Chat bonus',-- });-- }-- } catch { /* non-critical */ }-- }---- // Welcome first-time chatters in this streamer's channel-- if (client.streamId) {-- try {-- const stream = db.getStreamById(client.streamId);-- if (stream?.user_id) {-- const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;-- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {-- db.recordFirstChat(chatterKey, stream.user_id);-- const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';-- this.broadcastToStream(client.streamId, {-- type: 'system',-- message: `Welcome ${welcomeName} to the chat! 👋`,-- timestamp: new Date().toISOString(),-- });-- }-- }-- } catch { /* non-critical */ }-- }---- // Broadcast to appropriate audience-- if (client.streamId || client.channelUserId) {-- // Deliver to the streamer's whole channel room (every live slot + offline-- // viewers) plus the specific stream room. This subsumes cross-slot-- // forwarding so a viewer isn't interrupted when the streamer switches-- // slots or briefly drops offline.-- this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);-- // Surface on the homepage global feed (tagged with the channel).-- if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);-- else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);-- // Auto-translate (async): foreign → English for everyone, English → the channel's-- // language for a non-English streamer. Lands as a follow-up 'chat_translation'.-- this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);-- }-- if (client.streamId) {---- // Trigger server-side TTS synthesis (async, non-blocking).-- // identityKey uses the immutable login handle (or anon id) so the-- // per-user voice is stable even if the display name changes.-- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)-- this.synthesizeAndBroadcastTTS(-- client.streamId,-- username,-- text,-- chatMsg.voiceFX,-- null,-- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,-- null,-- chatMsg.id ? `m${chatMsg.id}` : null-- );---- // Check for 101soundboards links in the message (async, non-blocking)-- // floating-ok: processSoundboard catches and logs its own errors (never rejects)-- this.processSoundboard(ws, client, text);---- // Let AI chat viewers react to REAL typed chat (streamer or viewers).-- // Bots inject via broadcastToStream directly, so this path only sees-- // genuine human messages.-- try {-- let isMod = false;-- try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }-- require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {-- username,-- message: text,-- userId: client.user?.id || null,-- anonId: client.user ? null : (client.anonId || null),-- isStreamer: !!(client.user && client.user.id === client.channelUserId),-- isMod,-- msgId: chatMsg && chatMsg.id ? chatMsg.id : null,-- channelUserId: client.channelUserId || null,-- });-- } catch { /* non-critical */ }---- } else if (!client.channelUserId) {-- // Pure global chat (homepage) — offline channel messages were already-- // delivered to the channel room above.-- this.broadcastGlobal(chatMsg);-- } else if (this._channelOwnerInRoom(client.channelUserId)) {-- // OFFLINE channel chat with the channel OWNER present in their own room:-- // synthesize TTS so their "TTS on my channel even when offline" option has-- // audio to play (client-side settings + the one-speaking-tab lock decide-- // whether it's actually audible). Owner absent = skip the synth cost.-- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)-- this.synthesizeAndBroadcastTTS(-- null,-- username,-- text,-- chatMsg.voiceFX,-- null,-- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,-- client.channelUserId,-- chatMsg.id ? `m${chatMsg.id}` : null-- );-- }---- // Merge real chat into the streamer's PowerChat unified overlay (chat:write).-- // Scoped to the CHANNEL, not the live session. This used to sit inside the-- // `if (client.streamId)` block above, so it only fired while the streamer was-- // live — yet viewers can chat in a channel whenever they like, and those-- // messages are already persisted and broadcast channel-wide. The overlay simply-- // never saw them. Chat is a property of the channel; only TTS, soundboards and-- // AI viewers genuinely need a live stream.-- try {-- const pc = require('../integrations/powerchat-platform');-- // Relay is per CHANNEL, not per slot: a popout pinned to an expired stream-- // id keeps chatting in the same channel, so the slot check follows the-- // channel's current live stream rather than the stale id.-- if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {-- let isMod = false, isSub = false;-- try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }-- try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }-- pc.forwardChat(client.channelUserId, {-- chatterName: username,-- externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),-- message: text,-- avatarUrl: client.user?.avatar_url || undefined,-- isModerator: isMod,-- isSubscriber: isSub,-- });-- }-- } catch { /* non-critical */ }-- }---- handleBangCommand(ws, client, text) {-- const parts = text.trim().split(/\s+/);-- const cmd = parts[0].toLowerCase();---- // Arena: !hype / !beef / !arena (server/arena/arena-chat.js) — chat can only hype; the Arena is pure mic-- try { if (require('../arena/arena-chat').handle(this, ws, client, cmd, parts)) return; } catch (e) { console.warn('[Arena] chat command:', e.message); }---- if (cmd === '!gotti') {-- const username = client.user ? client.user.display_name : client.anonId;-- const coreUsername = client.user ? client.user.username : null;-- const role = client.user ? client.user.role : 'anon';-- const gottiMsg = {-- type: 'gotti',-- username,-- core_username: coreUsername,-- user_id: client.user?.id || null,-- anon_id: client.anonId,-- role,-- stream_id: client.streamId,-- is_global: !client.streamId,-- avatar_url: client.user?.avatar_url || null,-- profile_color: client.user?.profile_color || '#999',-- message: GOTTI_CAPTION,-- gif_url: GOTTI_GIF_URL,-- source_url: 'https://tenor.com/view/deez-something-came-in-the-mail-today-deez-nuts-discord-gif-20388619',-- timestamp: new Date().toISOString(),-- };---- if (client.user?.id) {-- try {-- const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);-- if (cosmeticProfile.nameFX) gottiMsg.nameFX = cosmeticProfile.nameFX;-- if (cosmeticProfile.particleFX) gottiMsg.particleFX = cosmeticProfile.particleFX;-- if (cosmeticProfile.hatFX) gottiMsg.hatFX = cosmeticProfile.hatFX;-- } catch { /* non-critical */ }---- try {-- const tags = require('./tags');-- const tagProfile = tags.getTagProfile(client.user.id);-- if (tagProfile) gottiMsg.tag = tagProfile;-- } catch { /* non-critical */ }-- }---- if (client.streamId) {-- this.broadcastToStream(client.streamId, gottiMsg);-- this.forwardToGlobal(client.streamId, gottiMsg);-- } else {-- this.broadcastGlobal(gottiMsg);-- }-- return;-- }---- if (cmd === '!sb') {-- if (!client.streamId) {-- this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });-- return;-- }-- const chatSettings = this._getChannelChatSettings(client.streamId);-- if (chatSettings.soundboard_enabled === 0) {-- this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });-- return;-- }-- const sbArgs = text.slice(parts[0].length).trim();-- const parsed = soundboard.parseSoundboardMessage(`!sb ${sbArgs}`, {-- allowPitch: chatSettings.soundboard_allow_pitch !== 0,-- allowSpeed: chatSettings.soundboard_allow_speed !== 0,-- });-- if (!parsed) {-- this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });-- return;-- }-- // floating-ok: processSoundboard catches and logs its own errors (never rejects)-- this.processSoundboard(ws, client, text);-- return;-- }---- if (!client.streamId) {-- this.sendTo(ws, { type: 'system', message: 'Media request commands only work in a stream chat.' });-- return;-- }---- const args = text.slice(parts[0].length).trim();---- try {-- const stream = db.getStreamById(client.streamId);-- if (!stream?.user_id) {-- this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });-- return;-- }---- const mediaQueue = require('../media/media-queue');---- switch (cmd) {-- case '!sr':-- case '!yt':-- case '!youtube':-- case '!req':-- case '!request': {-- if (!client.user?.id) {-- this.sendTo(ws, { type: 'system', message: 'You must be logged in to request media.' });-- return;-- }---- mediaQueue.addRequest({-- streamerId: stream.user_id,-- streamId: client.streamId,-- userId: client.user.id,-- username: client.user.display_name || client.user.username,-- input: args,-- }).then((request) => {-- this.broadcastToStream(client.streamId, {-- type: 'system',-- message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,-- timestamp: new Date().toISOString(),-- });-- this.sendTo(ws, {-- type: 'coin_earned',-- coins: 0,-- currency: 'gold',-- total: null, // client refreshes via /api/coins/balance (network wallet)-- reason: 'Media request purchase',-- });-- }).catch((err) => {-- this.sendTo(ws, { type: 'system', message: err.message || 'Failed to add media request.' });-- });-- return;-- }---- case '!queue': {-- const state = mediaQueue.getState(stream.user_id);-- const items = state.queue.slice(0, 3);-- if (!items.length) {-- this.sendTo(ws, { type: 'system', message: 'The media queue is empty. Use !sr, !yt, !youtube, !req, or !request with a URL to queue something.' });-- return;-- }-- const summary = items.map((item, index) => `#${index + 1} ${item.title}`).join(' • ');-- this.sendTo(ws, { type: 'system', message: `Queued: ${summary}` });-- return;-- }---- case '!np':-- case '!nowplaying':-- case '!watching': {-- const state = mediaQueue.getState(stream.user_id);-- if (state.now_playing) {-- const np = state.now_playing;-- const durText = np.duration_seconds ? ` [${Math.floor(np.duration_seconds / 60)}m${np.duration_seconds % 60}s]` : '';-- this.sendTo(ws, { type: 'system', message: `Now playing: ${np.title}${durText} (requested by ${np.username})` });-- } else if (state.queue[0]) {-- this.sendTo(ws, { type: 'system', message: `Nothing is playing yet. Up next: ${state.queue[0].title}` });-- } else {-- this.sendTo(ws, { type: 'system', message: 'Nothing is playing right now.' });-- }-- return;-- }---- case '!skip': {-- if (!this.canModerate(client) && client.user?.id !== stream.user_id) {-- this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });-- return;-- }-- const ended = mediaQueue.finishCurrent(stream.user_id, 'skipped');-- const next = mediaQueue.startNext(stream.user_id);-- if (ended) {-- this.broadcastToStream(client.streamId, {-- type: 'system',-- message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,-- timestamp: new Date().toISOString(),-- });-- } else {-- this.sendTo(ws, { type: 'system', message: 'Nothing is currently playing.' });-- }-- return;-- }---- case '!mediahelp': {-- this.sendTo(ws, { type: 'system', message: 'Media commands: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying, !skip' });-- return;-- }---- // ── Cozmo robot commands ───────────────-- case '!forward':-- case '!backward':-- case '!left':-- case '!right':-- case '!liftup':-- case '!liftdown':-- case '!headup':-- case '!headdown': {-- const controlServer = require('../controls/control-server');-- const cozmoMap = {-- '!forward': 'forward', '!backward': 'backward',-- '!left': 'turn_left', '!right': 'turn_right',-- '!liftup': 'lift_up', '!liftdown': 'lift_down',-- '!headup': 'head_up', '!headdown': 'head_down',-- };-- const user = db.getUserById(stream.user_id);-- if (!user) return;-- const hwWs = controlServer.hardwareClients.get(user.stream_key);-- if (!hwWs || hwWs.readyState !== 1) {-- this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });-- return;-- }-- hwWs.send(JSON.stringify({-- type: 'command',-- command: cozmoMap[cmd],-- from_user: client.user?.display_name || `anon${client.anonId || ''}`,-- timestamp: new Date().toISOString(),-- }));-- return;-- }---- case '!say': {-- if (!args) return;-- const controlServer2 = require('../controls/control-server');-- const user2 = db.getUserById(stream.user_id);-- if (!user2) return;-- const hwWs2 = controlServer2.hardwareClients.get(user2.stream_key);-- if (!hwWs2 || hwWs2.readyState !== 1) {-- this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });-- return;-- }-- hwWs2.send(JSON.stringify({-- type: 'command',-- command: `say:${args.slice(0, 200)}`,-- from_user: client.user?.display_name || `anon${client.anonId || ''}`,-- timestamp: new Date().toISOString(),-- }));-- return;-- }---- default:-- // Unknown !command → try a per-channel viewer-uploaded sound clip.-- // Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.-- this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));-- return;-- }-- } catch (err) {-- this.sendTo(ws, { type: 'system', message: err.message || 'Media command failed.' });-- }-- }---- /**-- * Play a per-channel viewer-uploaded sound clip (triggered by !command).-- * Silent no-op when the command is not a registered sound so unknown-- * commands don't spam the chat.-- */-- triggerChannelSound(ws, client, stream, command, args = [], relay = null) {-- try {-- if (!stream?.user_id || !command) return;-- const cmd = String(command).toLowerCase();-- const sound = db.getChannelSoundByCommand(stream.user_id, cmd);-- if (!sound) return; // not a sound command — stay silent---- const chatSettings = this._getChannelChatSettings(client.streamId);-- if (chatSettings.custom_sounds_enabled === 0) {-- if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });-- return;-- }---- // Pitch/speed args (e.g. "!honk 500p 0.5"), clamped to the channel's limits.-- const mods = soundboard.parseModifiers(args || [], {-- allowPitch: chatSettings.soundboard_allow_pitch !== 0,-- allowSpeed: chatSettings.soundboard_allow_speed !== 0,-- minSpeed: chatSettings.sound_min_speed,-- maxSpeed: chatSettings.sound_max_speed,-- minPitch: chatSettings.sound_min_pitch_rate,-- maxPitch: chatSettings.sound_max_pitch_rate,-- });---- // Banned users can't trigger sounds-- if (client.user && db.isUserBanned(client.user.id, client.streamId)) return;-- if (client.ip && !this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, client.streamId)) return;---- // Rate limits — reuse the soundboard limiter (per-user + per-stream window)-- const now = Date.now();-- const rateKey = `${client.streamId}:${relay ? `rs${relay.username}` : (client.user ? `u${client.user.id}` : `a${client.anonId}`)}`;-- const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;-- if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {-- const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);-- if (ws) this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before triggering another sound.` });-- return;-- }-- const streamWindow = this.soundboardStreamLimits.get(client.streamId) || { count: 0, windowStart: now };-- if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {-- streamWindow.count = 0;-- streamWindow.windowStart = now;-- }-- if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {-- if (ws) this.sendTo(ws, { type: 'system', message: 'Too many sounds are playing right now — try again shortly.' });-- return;-- }---- // Read + encode the clip-- if (!sound.url || !fs.existsSync(sound.url)) return;-- let audioB64;-- try {-- audioB64 = fs.readFileSync(sound.url).toString('base64');-- } catch { return; }---- this.soundboardRateLimits.set(rateKey, now);-- streamWindow.count += 1;-- this.soundboardStreamLimits.set(client.streamId, streamWindow);---- const username = relay ? relay.username-- : (client.user ? (client.user.display_name || client.user.username) : (client.anonId || 'someone'));---- // Announce as a RICH chat message (same identity/cosmetics/tag as a normal-- // message) so it shows the user's nametag, badges, avatar and cosmetics —-- // not a plain "x played !command" text line.-- const soundMsg = {-- type: 'chat',-- username,-- core_username: relay ? null : (client.user ? client.user.username : null),-- user_id: client.user?.id || null,-- anon_id: relay ? null : client.anonId,-- role: relay ? (relay.role || 'external') : (client.user ? client.user.role : 'anon'),-- message: `played !${cmd}`,-- message_type: 'channel-sound',-- sound: { command: cmd, pitch: mods.pitch, speed: mods.speed, pitchShift: mods.pitchShift, args: (args || []).join(' '), emoteCode: sound.emote_code || '' },-- stream_id: client.streamId,-- is_global: !client.streamId,-- avatar_url: (relay ? relay.avatar_url : client.user?.avatar_url) || null,-- profile_color: (relay ? relay.profile_color : client.user?.profile_color) || '#999',-- source_platform: relay ? relay.sourcePlatform : undefined,-- timestamp: new Date().toISOString(),-- };-- if (!relay && client.user?.id) {-- try {-- const cp = cosmetics.getCosmeticProfile(client.user.id);-- if (cp.nameFX) soundMsg.nameFX = cp.nameFX;-- if (cp.particleFX) soundMsg.particleFX = cp.particleFX;-- if (cp.hatFX) soundMsg.hatFX = cp.hatFX;-- } catch { /* non-critical */ }-- try {-- const tagProfile = require('./tags').getTagProfile(client.user.id);-- if (tagProfile) soundMsg.tag = tagProfile;-- } catch { /* non-critical */ }-- }-- // Persist the announce so it survives a reload — history rebuilds the-- // rich sound row from metadata (the audio itself is never replayed).-- try {-- const saved = db.saveChatMessage({-- stream_id: client.streamId || null,-- channel_user_id: stream.user_id,-- user_id: client.user?.id,-- anon_id: relay ? null : client.anonId,-- username,-- message: soundMsg.message,-- message_type: 'channel-sound',-- is_global: false,-- source_platform: relay ? relay.sourcePlatform : null,-- metadata: { sound: soundMsg.sound },-- });-- if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);-- } catch { /* non-critical */ }-- this.broadcastToStream(client.streamId, soundMsg);-- // Also surface it on the global chat feed / global overlay (with stream_channel).-- this.forwardToGlobal(client.streamId, soundMsg);-- this.broadcastToStream(client.streamId, {-- type: 'soundboard-audio',-- username,-- title: `!${cmd}`,-- audio: audioB64,-- mimeType: sound.mime || 'audio/mpeg',-- pitch: mods.pitch,-- speed: mods.speed,-- pitchShift: mods.pitchShift,-- source: 'channel-sound',-- timestamp: new Date().toISOString(),-- });-- } catch (err) {-- console.error('[ChannelSound] trigger error:', err.message);-- }-- }---- /**-- * Handle chat commands-- */-- handleCommand(ws, client, text) {-- const parts = text.slice(1).split(' ');-- const cmd = parts[0].toLowerCase();-- const args = parts.slice(1).join(' ');-- const argParts = parts.slice(1);---- switch (cmd) {-- case 'ai': {-- // /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.-- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }-- try {-- const engine = require('../integrations/ai-chatbot-service');-- const reply = engine.onModCommand ? engine.onModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username }) : 'AI viewers: command not supported by this engine.';-- this.sendTo(ws, { type: 'system', message: reply || 'ok' });-- } catch (e) { this.sendTo(ws, { type: 'system', message: `AI viewers: ${e.message}` }); }-- return;-- }-- case 'help':-- this.sendTo(ws, {-- type: 'system',-- message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +-- `\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +-- (this.canModerate(client)-- ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear, /slow <seconds>`-- : ''),-- });-- break;---- case 'tts':-- if (!args) {-- this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });-- return;-- }-- {-- const ttsMsg = {-- type: 'tts',-- username: client.user?.display_name || client.anonId,-- core_username: client.user?.username || null,-- message: args,-- timestamp: new Date().toISOString(),-- };-- // Attach voice cosmetic if equipped-- let voiceFX = null;-- if (client.user?.id) {-- try {-- const cp = cosmetics.getCosmeticProfile(client.user.id);-- if (cp.voiceFX) {-- ttsMsg.voiceFX = cp.voiceFX;-- voiceFX = cp.voiceFX;-- }-- } catch { /* non-critical */ }-- }-- this.broadcastToStream(client.streamId, ttsMsg);---- // Also synthesize server-side TTS for site-wide mode-- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)-- this.synthesizeAndBroadcastTTS(-- client.streamId,-- ttsMsg.username,-- args,-- voiceFX,-- null,-- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`-- );-- }-- break;---- case 'color':-- if (!client.user) {-- this.sendTo(ws, { type: 'system', message: 'You must be logged in to change color.' });-- } else if (!args || !/^#[0-9a-fA-F]{6}$/.test(args)) {-- this.sendTo(ws, { type: 'system', message: 'Usage: /color #ff00ff (hex color code)' });-- } else {-- db.run('UPDATE users SET profile_color = ? WHERE id = ?', [args, client.user.id]);-- client.user.profile_color = args;-- this.sendTo(ws, { type: 'system', message: `Color set to ${args}` });-- }-- break;---- case 'viewers': {-- const count = this.getStreamViewerCount(client.streamId);-- this.sendTo(ws, { type: 'system', message: `${count} viewer(s) in chat` });-- break;-- }---- case 'uptime': {-- if (!client.streamId) {-- this.sendTo(ws, { type: 'system', message: 'Not in a stream chat.' });-- break;-- }-- try {-- const stream = db.getStreamById(client.streamId);-- if (stream && stream.started_at) {-- const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();-- const elapsed = Date.now() - start;-- const hours = Math.floor(elapsed / 3600000);-- const minutes = Math.floor((elapsed % 3600000) / 60000);-- const seconds = Math.floor((elapsed % 60000) / 1000);-- const parts = [];-- if (hours > 0) parts.push(`${hours}h`);-- parts.push(`${minutes}m`);-- parts.push(`${seconds}s`);-- this.sendTo(ws, { type: 'system', message: `Stream uptime: ${parts.join(' ')}` });-- } else {-- this.sendTo(ws, { type: 'system', message: 'Stream is offline.' });-- }-- } catch {-- this.sendTo(ws, { type: 'system', message: 'Could not determine uptime.' });-- }-- break;-- }---- case 'w':-- case 'whisper':-- case 'msg': {-- this.sendTo(ws, { type: 'system', message: 'Whispers have been replaced by DMs! Click the message icon in the navbar to open Messenger.' });-- break;-- }---- case 'me': {-- if (!args) {-- this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });-- break;-- }-- const username = client.user?.display_name || client.anonId;-- this.broadcastToStream(client.streamId, {-- type: 'chat',-- username,-- core_username: client.user?.username || null,-- role: client.user?.role || 'anon',-- message: `* ${username} ${args}`,-- is_action: true,-- timestamp: new Date().toISOString(),-- });-- break;-- }---- case 'ban':-- this.handleModAction(ws, client, 'ban', args);-- break;---- case 'unban':-- this.handleModAction(ws, client, 'unban', args);-- break;---- case 'timeout':-- this.handleModAction(ws, client, 'timeout', args);-- break;---- case 'clear':-- if (this.canModerate(client)) {-- this.broadcastToStream(client.streamId, { type: 'clear' });-- this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');-- } else {-- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });-- }-- break;---- case 'slow': {-- if (this.canModerate(client)) {-- let seconds;-- if (args === 'off' || args === 'disable' || args === '0') {-- seconds = 0;-- } else {-- seconds = parseInt(args);-- if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;-- }-- // Per-stream slow mode (not global). OpenVibe.Chat owns channel_moderation_settings-- // (roadmap T3) and persists /slow itself; Live's own chat server only holds it in memory.-- if (client.streamId) {-- this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);-- }-- // Dedicated slowmode event so clients can show/hide UI-- this.broadcastToStream(client.streamId, {-- type: 'slowmode',-- seconds,-- });-- const msg = seconds > 0-- ? `Slow mode enabled: ${seconds}s between messages`-- : 'Slow mode disabled.';-- this.broadcastToStream(client.streamId, {-- type: 'system',-- message: msg,-- });-- this.logChatModeration(client, 'slowmode_update', { seconds });-- } else {-- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });-- }-- break;-- }---- case 'paste': {-- // /paste <content> — create a quick paste from chat-- if (!args.trim()) {-- this.sendTo(ws, { type: 'system', message: 'Usage: /paste <content to share>' });-- break;-- }-- {-- const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;-- const userId = client.userId || client.user?.id || null;-- // Pastes live in OpenVibe.Media now — post through the media client.-- require('../pastes-client').createPaste({-- title,-- content: args.trim(),-- language: 'auto',-- visibility: 'public',-- user_id: userId || undefined,-- stream_id: client.streamId || undefined,-- }).then((paste) => {-- const siteUrl = process.env.SITE_URL || '';-- const pasteUrl = `${siteUrl}/p/${paste.slug}`;-- // Show link to everyone in stream-- this.broadcastToStream(client.streamId, {-- type: 'system',-- message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,-- });-- }).catch((err) => {-- console.error('[Chat] /paste error:', err.message);-- this.sendTo(ws, { type: 'system', message: 'Failed to create paste.' });-- });-- }-- break;-- }---- default:-- this.sendTo(ws, { type: 'system', message: `Unknown command: /${cmd}. Type /help for a list.` });-- }-- }---- /**-- * Handle mod actions (ban, unban, timeout)-- */-- handleModAction(ws, client, action, args) {-- if (!this.canModerate(client)) {-- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });-- return;-- }---- const target = args.split(' ')[0];-- if (!target) return;-- // Where the ban applies. In an offline channel room it is that channel's latest stream: a-- // null stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which-- // only global chat — where canModerate() already requires global staff — may write.-- const scopeStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);-- if (!scopeStreamId && client.channelUserId) {-- this.sendTo(ws, { type: 'system', message: 'This channel has no stream to moderate yet.' });-- return;-- }-- const scoped = !!scopeStreamId;---- switch (action) {-- case 'ban': {-- const targetUser = db.getUserByUsername(target);-- if (targetUser) {-- // Prevent non-admins from banning admins-- if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && !permissions.isAdmin(client.user)) {-- this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });-- return;-- }-- db.run(-- `INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,-- [scopeStreamId, targetUser.id, 'Banned by moderator', client.user.id]-- );-- this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });-- this.broadcastToStream(client.streamId, {-- type: 'system', message: `${target} has been banned.`-- });-- this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);-- } else {-- // Ban by anon ID-- const anonTarget = this.findClientByAnonId(target, client.streamId);-- if (anonTarget) {-- db.run(-- `INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,-- [scopeStreamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]-- );-- this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });-- }-- this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });-- }-- break;-- }-- case 'timeout': {-- const duration = parseInt(args.split(' ')[1]) || 300; // Default 5 min-- const targetUser = db.getUserByUsername(target);-- const expires = new Date(Date.now() + duration * 1000).toISOString();-- if (targetUser) {-- db.run(-- `INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,-- [scopeStreamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]-- );-- this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);-- }-- this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });-- break;-- }-- case 'unban': {-- const targetUser = db.getUserByUsername(target);-- if (targetUser) {-- // A channel unban lifts that channel's rows only; site-wide rows are global chat's.-- if (scoped) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [targetUser.id, scopeStreamId]);-- else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [targetUser.id]);-- this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);-- }-- this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });-- break;-- }-- }-- }---- // ── Helper methods ───────────────────────────────────────-- /**-- * Synthesize TTS audio for a chat message and broadcast to stream.-- * Runs asynchronously — does not block message delivery.-- */-- // Is the channel owner connected to their OWN channel room right now? Gates-- // offline-channel TTS synthesis so we never pay for audio nobody will hear.-- _channelOwnerInRoom(channelUserId) {-- if (!channelUserId) return false;-- for (const [ws, client] of this.clients) {-- if (ws.readyState !== WebSocket.OPEN) continue;-- if (client.user && Number(client.user.id) === Number(channelUserId)-- && Number(client.channelUserId) === Number(channelUserId)) return true;-- }-- return false;-- }---- // TTS audio delivery: to the stream room while live, to the CHANNEL room for-- // offline channel chat (streamId null).-- _broadcastTtsPayload(streamId, channelUserId, payload) {-- if (streamId) this.broadcastToStream(streamId, payload);-- else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);-- }---- // streamId may be null for OFFLINE channel chat — pass channelUserId instead and-- // the audio is delivered to the channel room rather than a stream room.-- async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {-- // Queue accounting key: per-stream when live, per-channel when offline.-- const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);-- // Duplicate suppression by MESSAGE IDENTITY, not content: the same chat-- // message synthesized twice (duplicated bridge, double-fired relay) is a dupe;-- // a user legitimately typing the same text again is NOT and must be read-- // again. Callers pass ttsKey (the persisted message id). The content fallback-- // for id-less paths uses a 2s window — wide enough for a racing double-- // delivery, far too narrow to eat a real repeat.-- if (!this._recentTtsDedupe) this._recentTtsDedupe = new Map();-- const dedupeKey = ttsKey ? `k:${queueKey}|${ttsKey}` : `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;-- const windowMs = ttsKey ? 30000 : 2000;-- const nowMs = Date.now();-- const lastMs = this._recentTtsDedupe.get(dedupeKey);-- if (lastMs && nowMs - lastMs < windowMs) { console.log(`[TTS] deduped duplicate synth for ${username} in ${queueKey} (${ttsKey ? 'same message id' : 'same content <2s'})`); return; }-- this._recentTtsDedupe.set(dedupeKey, nowMs);-- if (this._recentTtsDedupe.size > 500) {-- for (const [k, t] of this._recentTtsDedupe) if (nowMs - t > 60000) this._recentTtsDedupe.delete(k);-- }-- try {-- // "." prefix = user opted this message out of TTS — never synthesize or broadcast it.-- if (String(text || '').trimStart().startsWith('.')) return;---- const settings = ttsEngine.getTTSSettings();-- if (!settings.enabled) return;---- // Queue limit checks-- const limits = ttsEngine.getQueueLimits();-- const globalCount = this.ttsQueueSize.get(queueKey) || 0;-- if (globalCount >= limits.maxGlobal) return;---- // Determine voice ID from equipped cosmetic-- let voiceId = null;-- if (voiceFX?.itemId && ttsEngine.VOICE_CATALOG[voiceFX.itemId]) {-- voiceId = voiceFX.itemId;-- }---- // Increment queue counter-- this.ttsQueueSize.set(queueKey, globalCount + 1);---- // Honor the channel's configured TTS length (streamers can raise it up to 1200);-- // falls back to the site default when unset. Without this the server synth always-- // truncated at the global 200 even when the channel allowed more.-- let ttsMaxOverride;-- try { ttsMaxOverride = Number(this._getChannelChatSettings(streamId).tts_max_length) || undefined; } catch { /* use engine default */ }---- let result;-- if (!voiceId && settings.perUserVoices) {-- // No equipped cosmetic voice → give this chatter a stable per-username voice.-- const idKey = identityKey || username || 'anon';-- result = await ttsEngine.synthesizeUserVoice(text, idKey, username, ttsMaxOverride);-- } else {-- result = await ttsEngine.synthesize(text, voiceId || settings.defaultVoice, username, ttsMaxOverride);-- }---- // Decrement queue counter-- const current = this.ttsQueueSize.get(queueKey) || 1;-- this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));---- if (!result) return;---- // Broadcast TTS audio to all clients in the stream-- this._broadcastTtsPayload(streamId, channelUserId, {-- type: 'tts-audio',-- username,-- // Stable sender identity ("user:<login>" / "anon:<id>") so clients can-- // skip their OWN message's TTS locally — senders were hearing their-- // message twice (their tab + the stream audio).-- sender_key: identityKey || undefined,-- // Unique per utterance — clients dedupe playback on this, so identical-- // TEXT from separate messages still reads every time.-- ttsKey: ttsKey || undefined,-- message: text,-- audio: result.audio,-- mimeType: result.mimeType,-- engine: result.engine,-- voiceName: result.voiceName,-- voiceId: result.voiceId,-- fallback: result.fallback || false,-- source_platform: sourcePlatform || undefined,-- timestamp: new Date().toISOString(),-- });-- } catch (err) {-- // Ensure queue counter is decremented on error-- const current = this.ttsQueueSize.get(queueKey) || 1;-- this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));-- console.error('[TTS] Synthesis broadcast error:', err.message);-- }-- }---- /**-- * Process a chat message for 101soundboards links or !sb commands.-- * Fetches the audio, caches it, and broadcasts to stream clients.-- */-- async processSoundboard(ws, client, text) {-- try {-- if (!soundboard.isConfigured()) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: 'Soundboard is not configured on this server.' });-- }-- return;-- }---- const streamId = client?.streamId || null;-- if (!streamId) return;---- const chatSettings = this._getChannelChatSettings(streamId);-- if (chatSettings.soundboard_enabled === 0) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });-- }-- return;-- }---- const parsed = soundboard.parseSoundboardMessage(text, {-- allowPitch: chatSettings.soundboard_allow_pitch !== 0,-- allowSpeed: chatSettings.soundboard_allow_speed !== 0,-- });-- if (!parsed) return;---- const bannedIds = new Set(soundboard.normalizeBannedIds(chatSettings.soundboard_banned_ids));-- if (bannedIds.has(String(parsed.soundId))) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: 'That 101soundboards sound is blocked by this streamer.' });-- }-- return;-- }---- const userKey = client.user?.id ? `user:${client.user.id}` : `anon:${client.anonId || client.ip}`;-- const rateKey = `${streamId}:${userKey}`;-- const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;-- const now = Date.now();-- if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {-- if (String(text || '').trim().startsWith('!sb')) {-- const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);-- this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before using another soundboard clip.` });-- }-- return;-- }---- // Per-stream global rate limit (15 sounds/min across all users)-- const streamWindow = this.soundboardStreamLimits.get(streamId) || { count: 0, windowStart: now };-- if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {-- streamWindow.count = 0;-- streamWindow.windowStart = now;-- }-- if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: 'Too many soundboard clips are playing in this stream right now. Try again soon.' });-- }-- return;-- }-- streamWindow.count++;-- this.soundboardStreamLimits.set(streamId, streamWindow);---- const result = await soundboard.getSoundboardAudio(parsed.soundId);-- if (!result) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: 'Could not load that 101soundboards clip.' });-- }-- return;-- }---- this.soundboardRateLimits.set(rateKey, now);---- const username = client.user?.display_name || client.user?.username || client.anonId || 'anon';-- const coreUsername = client.user?.username || null;-- const role = client.user ? client.user.role : 'anon';---- const sbMsg = {-- type: 'chat',-- username,-- core_username: coreUsername,-- user_id: client.user?.id || null,-- anon_id: client.anonId,-- role,-- stream_id: streamId,-- avatar_url: client.user?.avatar_url || null,-- profile_color: client.user?.profile_color || '#999',-- message_type: 'soundboard',-- message: `played ${result.title}`,-- soundboard: {-- soundId: result.soundId,-- title: result.title,-- sourceUrl: result.sourceUrl,-- pitch: parsed.pitch,-- speed: parsed.speed,-- pitchShift: parsed.pitchShift,-- },-- timestamp: new Date().toISOString(),-- };-- // Persist so the announce survives a reload (rebuilt from metadata).-- try {-- const saved = db.saveChatMessage({-- stream_id: streamId,-- user_id: client.user?.id,-- anon_id: client.anonId,-- username,-- message: sbMsg.message,-- message_type: 'soundboard',-- is_global: false,-- metadata: { soundboard: sbMsg.soundboard },-- });-- if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);-- } catch { /* non-critical */ }-- this.broadcastToStream(streamId, sbMsg);---- this.broadcastToStream(streamId, {-- type: 'soundboard-audio',-- username,-- audio: result.audio,-- mimeType: result.mimeType,-- soundId: result.soundId,-- title: result.title,-- sourceUrl: result.sourceUrl,-- pitch: parsed.pitch,-- speed: parsed.speed,-- pitchShift: parsed.pitchShift,-- timestamp: new Date().toISOString(),-- });-- } catch (err) {-- if (String(text || '').trim().startsWith('!sb')) {-- this.sendTo(ws, { type: 'system', message: err.message || 'Could not load that 101soundboards clip.' });-- }-- console.error('[Soundboard] Process error:', err.message);-- }-- }---- /**-- * Can this client moderate their current stream's chat?-- * Uses the permission layer: admin, global_mod, stream owner, or channel mod.-- * Streamers do NOT get mod powers in other people's chats.-- */-- canModerate(client) {-- if (!client.user) return false;-- // Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).-- const sid = client.streamId || this._moderationStreamFor(client);-- return !!sid && permissions.canModerateStreamSync(client.user, sid) || permissions.isGlobalModOrAbove(client.user);-- }---- /** @deprecated Use canModerate(client) — kept temporarily for any external callers */-- isMod(client) {-- return this.canModerate(client);-- }---- findClientByAnonId(anonId, streamId) {-- for (const [, info] of this.clients) {-- if (info.anonId === anonId && info.streamId === streamId) {-- return info;-- }-- }-- return null;-- }---- /**-- * Find the IP address of a connected user by user ID.-- * Returns the IP from their most recent connection, or null if not connected.-- */-- getConnectedUserIp(userId) {-- for (const [, info] of this.clients) {-- if (info.user?.id === userId) return info.ip;-- }-- return null;-- }---- /**-- * Disconnect all WebSocket clients for a given user ID or IP.-- * Used after banning to immediately kick them.-- */-- disconnectUser({ userId, ip, streamId } = {}) {-- for (const [ws, info] of this.clients) {-- const matchUser = userId && info.user?.id === userId;-- const matchIp = ip && info.ip === ip;-- const matchStream = streamId ? info.streamId === streamId : true;-- if ((matchUser || matchIp) && matchStream) {-- this.sendTo(ws, { type: 'system', message: 'You have been banned.' });-- try { ws.close(1000, 'banned'); } catch {}-- }-- }-- }---- findWsByUsername(name, streamId) {-- const nameLower = name.toLowerCase();-- for (const [ws, info] of this.clients) {-- if (info.streamId !== streamId) continue;-- const uname = info.user?.display_name || info.user?.username || info.anonId;-- if (uname && uname.toLowerCase() === nameLower) return ws;-- }-- return null;-- }---- sendTo(ws, data) {-- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(JSON.stringify(data));-- }-- }---- /** Language a channel lives in ('en' when unknown). Cached inside i18n. */-- _channelLanguage(channelUserId) {-- if (!channelUserId) return 'en';-- try { return require('../i18n/translate').channelLanguage(channelUserId); } catch { return 'en'; }-- }---- /**-- * Translate a just-broadcast chat line and push the translation to the same rooms as a-- * 'chat_translation' event (clients attach it under the message by id). Persisted into-- * chat_messages.metadata so history shows it too. Fire-and-forget; never throws.-- */-- _maybeTranslate(chatMsg, channelUserId, streamId) {-- if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;-- let i18n;-- try { i18n = require('../i18n/translate'); } catch { return; }-- if (!i18n.available()) return;-- let chanUid = channelUserId || null;-- if (!chanUid && streamId) { try { chanUid = db.getStreamById(streamId)?.user_id || null; } catch { /* */ } }-- const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker-- i18n.translateChatMessage(text, chanUid).then((tr) => {-- if (!tr || !tr.text) return;-- const evt = {-- type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,-- stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),-- };-- this.broadcastToChannelRoom(chanUid, streamId, evt);-- if (streamId) this.forwardToGlobal(streamId, evt);-- else this.forwardToGlobalByChannel(chanUid, evt);-- if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }-- }).catch(() => { /* best-effort */ });-- }---- broadcastToStream(streamId, data) {-- const msg = JSON.stringify(data);-- for (const [ws, client] of this.clients) {-- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(msg);-- }-- }-- }---- /**-- * Deliver to a streamer's whole channel room: any client in that streamer's-- * channel (`channelUserId`, stable across slots + offline) OR in the specific-- * live-session stream room (`streamId`, for backward-compat with clients that-- * joined before channel rooms existed). Deduped per connection.-- */-- broadcastToChannelRoom(channelUserId, streamId, data) {-- const msg = JSON.stringify(data);-- for (const [ws, client] of this.clients) {-- if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;-- const inChannel = channelUserId && client.channelUserId === channelUserId;-- const inStream = streamId && client.streamId === streamId;-- if (inChannel || inStream) ws.send(msg);-- }-- }---- /**-- * Forward an OFFLINE channel message to the homepage global feed, tagged with-- * the channel username (parallels forwardToGlobal but keyed by user id since-- * there's no live session id to resolve).-- */-- forwardToGlobalByChannel(channelUserId, data) {-- if (!channelUserId) return;-- let username = null;-- try { username = db.getUserById(channelUserId)?.username; } catch { /* ignore */ }-- if (!username) return;-- const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });-- for (const [ws, client] of this.clients) {-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(globalMsg);-- }-- }-- }---- broadcastGlobal(data) {-- const msg = JSON.stringify(data);-- for (const [ws, client] of this.clients) {-- // Pure global clients only (see forwardToGlobal) — channel/stream viewers get-- // global activity via their dedicated cross-feed socket, not their main one.-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(msg);-- }-- }-- }---- /**-- * Notify everyone watching any of a channel owner's live streams that the-- * channel's emotes/sounds changed, so their chat pickers refresh live-- * (no page reload). No-op when the owner has no live streams / viewers.-- */-- broadcastToOwnerStreams(ownerUserId, data) {-- try {-- const streams = db.getLiveStreamsByUserId(ownerUserId) || [];-- if (!streams.length) return;-- const ids = new Set(streams.map(s => String(s.id)));-- const msg = JSON.stringify(data);-- for (const [ws, client] of this.clients) {-- if (ids.has(String(client.streamId)) && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(msg);-- }-- }-- } catch { /* ignore */ }-- }---- /**-- * Forward a stream message to all global-connected clients-- * so the global chat feed shows activity from every stream.-- */-- forwardToGlobal(streamId, data) {-- // Look up stream owner username + slot title/slug (cached per stream)-- if (!this._streamNameCache) this._streamNameCache = new Map();-- let info = this._streamNameCache.get(streamId);-- if (!info || typeof info !== 'object') {-- try {-- const stream = db.getStreamById(streamId);-- info = {-- username: stream?.username || `stream-${streamId}`,-- title: stream?.managed_stream_title || stream?.title || null,-- slug: stream?.managed_stream_slug || null,-- managedId: stream?.managed_stream_id || null,-- };-- this._streamNameCache.set(streamId, info);-- // Auto-expire cache after 5 min-- setTimeout(() => this._streamNameCache.delete(streamId), 300000);-- } catch {-- info = { username: `stream-${streamId}`, title: null, slug: null, managedId: null };-- }-- }-- const globalMsg = JSON.stringify({-- ...data,-- stream_channel: info.username,-- source_channel: info.username,-- source_stream_id: streamId,-- source_stream_title: info.title,-- source_slug: info.slug,-- source_managed_id: info.managedId,-- source_is_live: 1, // this path only fires for a live send-- });-- for (const [ws, client] of this.clients) {-- // Only PURE global clients (no stream AND no channel) — otherwise an-- // offline-channel viewer's main socket (streamId null, channelUserId set)-- // would render this via addChatMessage AND get it again as a cross-feed on-- // its dedicated global socket → duplicate. Channel/stream viewers receive the-- // cross-feed exclusively through their separate global-feed connection.-- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(globalMsg);-- }-- }-- }---- /**-- * Deliver a stream message to viewers of the SAME streamer's OTHER live slots-- * ("Show All Chats Under This Streamer"). Tagged with source-slot info so the-- * client can show a stream-title badge + let viewers hop between slots. Only-- * fires when the streamer has 2+ live slots.-- */-- forwardToStreamerRooms(streamId, data) {-- try {-- const stream = db.getStreamById(streamId);-- if (!stream || !stream.user_id) return;-- const siblings = db.getLiveStreamsByUserId(stream.user_id) || [];-- if (siblings.length < 2) return;-- const siblingIds = new Set(siblings.map(s => s.id));-- const payload = JSON.stringify({-- ...data,-- cross_stream: true,-- source_stream_id: streamId,-- source_stream_title: stream.managed_stream_title || stream.title || null,-- source_slug: stream.managed_stream_slug || null,-- source_managed_id: stream.managed_stream_id || null,-- source_channel: stream.username || null,-- source_is_live: 1,-- });-- for (const [ws, client] of this.clients) {-- if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)-- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(payload);-- }-- }-- } catch { /* non-critical */ }-- }---- _broadcastDeletedMessages(streamId, ids) {-- if (!Array.isArray(ids) || ids.length === 0) return;-- const payload = { type: 'delete-messages', ids };-- if (streamId) {-- this.broadcastToStream(streamId, payload);-- this.forwardToGlobal(streamId, payload);-- return;-- }-- this.broadcastAll(payload);-- }---- _sweepExpiredChatMessages() {-- try {-- const expired = db.deleteExpiredChatMessages(500);-- if (!expired.length) return;-- const byScope = new Map();-- for (const row of expired) {-- const key = row.stream_id ? `stream:${row.stream_id}` : 'global';-- if (!byScope.has(key)) byScope.set(key, []);-- byScope.get(key).push(row.id);-- }-- for (const [key, ids] of byScope.entries()) {-- const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);-- this._broadcastDeletedMessages(streamId, ids);-- }-- } catch (err) {-- console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);-- }-- }---- broadcastUserCount(streamId) {-- const count = this.getStreamViewerCount(streamId);-- const data = JSON.stringify({ type: 'user-count', count, stream_id: streamId });-- for (const [ws, client] of this.clients) {-- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {-- ws.send(data);-- }-- }-- // Persist to DB so the /api/streams endpoint returns real counts-- if (streamId) {-- try { db.updateViewerCount(streamId, count); } catch {}-- }-- }---- /**-- * Record viewer snapshots for all active streams.-- * Called every 60 seconds by the snapshot interval timer.-- */-- _recordViewerSnapshots() {-- // Collect unique active stream IDs-- const streamIds = new Set();-- for (const [, client] of this.clients) {-- if (client.streamId) streamIds.add(client.streamId);-- }-- for (const streamId of streamIds) {-- try {-- const count = this.getStreamViewerCount(streamId);-- const chatActivity = db.getRecentChatActivity(streamId, 5);-- db.insertViewerSnapshot(streamId, count, chatActivity);-- } catch (err) {-- // Non-critical — don't crash the chat server over analytics-- }-- }-- }---- /**-- * Push the current users list to all clients in a stream/global.-- * Throttled to avoid flooding on rapid join/leave bursts.-- */-- broadcastUsersList(streamId) {-- const key = `users-${streamId ?? 'global'}`;-- if (this._usersListTimers?.has(key)) return; // already scheduled-- if (!this._usersListTimers) this._usersListTimers = new Map();-- this._usersListTimers.set(key, setTimeout(() => {-- this._usersListTimers.delete(key);-- const users = this.getUserList(streamId);-- const data = JSON.stringify({ type: 'users-list', users });-- for (const [ws, client] of this.clients) {-- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {-- try { ws.send(data); } catch {}-- }-- }-- }, 500));-- }---- /**-- * Count unique IPs watching a stream (not raw connections).-- * Multiple tabs from the same IP count as one viewer.-- */-- getStreamViewerCount(streamId) {-- const ips = new Set();-- for (const [, client] of this.clients) {-- if (client.streamId === streamId && client.ip) {-- ips.add(client.ip);-- }-- }-- return ips.size;-- }---- getTotalConnections() {-- return this.clients.size;-- }---- /**-- * Send a DM payload to all WebSocket connections belonging to a given user ID.-- * Used by the DM REST API for real-time delivery.-- */-- sendDm(userId, data) {-- const convId = data?.conversation_id;-- if (convId && !dm.isParticipant(convId, userId)) {-- if (DEBUG_DM_DELIVERY) {-- console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);-- }-- return;-- }-- const payload = JSON.stringify(data);-- for (const [ws, client] of this.clients) {-- if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {-- if (DEBUG_DM_DELIVERY) {-- console.debug(`[DM] delivering ${data.type} conversation ${convId} to ws user ${client.user.id} (${client.user.username})`);-- }-- try {-- if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) {-- ws.send(payload);-- }-- } catch { /* non-critical */ }-- }-- }-- }---- /**-- * Push a profile/identity update to all chat connections belonging to a user.-- * Refreshes cached client.user so subsequent messages use the new info.-- */-- sendUserUpdate(userId, userData) {-- const freshUser = db.getUserById(userId);-- const payload = JSON.stringify({-- type: 'user-updated',-- user: {-- id: userData.id,-- username: userData.username,-- display_name: userData.display_name,-- role: userData.role,-- avatar_url: userData.avatar_url,-- profile_color: userData.profile_color,-- },-- });-- for (const [ws, client] of this.clients) {-- if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {-- // Refresh cached user object so future messages use new name-- if (freshUser) client.user = freshUser;-- try {-- if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) ws.send(payload);-- } catch { /* non-critical */ }-- }-- }-- }---- /**-- * Broadcast a message to ALL connected chat clients (every stream + global).-- * Used for server-wide announcements (restarts, updates).-- */-- broadcastAll(data) {-- const msg = JSON.stringify(data);-- for (const [ws] of this.clients) {-- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {-- ws.send(msg);-- }-- }-- }---- close() {-- if (this.wss) {-- if (this.heartbeatInterval) {-- clearInterval(this.heartbeatInterval);-- this.heartbeatInterval = null;-- }-- if (this._snapshotInterval) {-- clearInterval(this._snapshotInterval);-- this._snapshotInterval = null;-- }-- if (this._autoDeleteSweepInterval) {-- clearInterval(this._autoDeleteSweepInterval);-- this._autoDeleteSweepInterval = null;-- }-- this.wss.clients.forEach(ws => ws.close());-- this.wss.close();-- }-- }---- /**-- * Notify the stream owner that a new IP needs approval.-- * Sends a system message only to the streamer's connection.-- */-- _notifyStreamerPendingIp(streamId, streamOwnerId, username, ip) {-- if (!streamOwnerId) return;-- try {-- for (const [ws, client] of this.clients) {-- if (client.user?.id === streamOwnerId && client.streamId === streamId) {-- this.sendTo(ws, {-- type: 'system',-- message: `⏳ New IP needs approval: ${username} (${ip}). Open Dashboard → Moderation to review.`,-- ip_approval_alert: true,-- });-- break;-- }-- }-- } catch { /* non-critical */ }-- }---- /**-- * Donation / goal alert sounds are OpenVibe.Chat's now (roadmap T3): the mapping lives on-- * channel_moderation_settings, which Chat owns, and Chat resolves it from its own row and-- * broadcasts the clip. When Live runs the chat server itself (rollback / dev) there is no Chat to-- * ask, so this is a no-op; with CHAT_AUTHORITY=chat server/monetization/alerts.js sends Chat an `alert` event.-- */-- playAlertSound() { /* Chat plays alerts; see server/monetization/alerts.js */ }---- /**-- * Get channel moderation settings for a stream.-- * Caches the channel lookup to avoid repeated DB queries.-- */-- _getChannelChatSettings(streamId) {-- const defaults = {-- slow_mode_seconds: 0, followers_only: 0, emote_only: 0,-- allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,-- caps_percentage_limit: 0, aggressive_filter: 0, max_message_length: 500,-- slur_filter_enabled: 0, slur_filter_use_builtin: 1, slur_filter_terms: '', slur_filter_regexes: '', slur_filter_nudge_message: '', slur_filter_disabled_categories: '[]',-- ip_approval_mode: 0,-- gifs_enabled: 1,-- soundboard_enabled: 1,-- soundboard_allow_pitch: 1,-- soundboard_allow_speed: 1,-- soundboard_banned_ids: '',-- viewer_auto_delete_enabled: 1,-- viewer_delete_all_enabled: 1,-- custom_emotes_enabled: 1,-- custom_sounds_enabled: 1,-- max_sound_seconds: 10,-- uploads_mods_only: 0,-- emote_scale: 100,-- sound_min_speed: 0.5,-- sound_max_speed: 3.0,-- sound_min_pitch_cents: -1200,-- sound_max_pitch_cents: 1200,-- };-- // Derive pitch rate bounds from the configured cents (2^(cents/1200)).-- const finalize = (s) => ({-- ...s,-- sound_min_pitch_rate: Math.pow(2, (Number(s.sound_min_pitch_cents) ?? -1200) / 1200),-- sound_max_pitch_rate: Math.pow(2, (Number(s.sound_max_pitch_cents) ?? 1200) / 1200),-- });-- if (!streamId) return finalize(defaults);-- try {-- const stream = db.getStreamById(streamId);-- if (!stream) return finalize(defaults);-- const channel = stream.channel_id ? db.getChannelById(stream.channel_id) : db.getChannelByUserId(stream.user_id);-- if (!channel) return finalize(defaults);-- return finalize({ ...defaults, ...moderation.settingsSync(channel.id) });-- } catch {-- return finalize(defaults);-- }-- }---- /**-- * Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).-- * Non-critical — failures are silently ignored.-- */-- logChatModeration(client, actionType, details = {}, targetUserId = null) {-- try {-- const stream = client.streamId ? db.getStreamById(client.streamId) : null;-- const channel = stream?.channel_id-- ? db.getChannelById(stream.channel_id)-- : stream ? db.getChannelByUserId(stream.user_id) : null;-- db.logModerationAction({-- scope_type: channel ? 'channel' : 'site',-- scope_id: channel?.id || undefined,-- actor_user_id: client.user?.id || undefined,-- target_user_id: targetUserId || undefined,-- action_type: actionType,-- details: { stream_id: client.streamId || null, ...details },-- });-- } catch { /* non-critical */ }-- }--}----// Live-owned data Chat caches, written by Live's own routes (IP approvals, bans): after the write Chat is told--// to reload it instead of waiting for its cache to expire.--const OBSERVED_DB = {-- approveIp: (a) => ({ approvals: Number(a[0]) || undefined }),-- revokeIpApproval: (a) => ({ approvals: Number(a[0]) || undefined }),-- forgiveBan: () => ({ bans: true }),--};--const PRESENCE_MS = 3000;----/**-- * CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server. Live's producers reach it through-- * chat-delivery.js (Chat's typed ingress); this object keeps the rest of the ChatServer surface Live's-- * modules use, without listening or serving:-- * - synchronous reads (connection and viewer counts, slow modes, a connected user's address) come from-- * Chat's presence snapshot (GET /internal/chat/presence), polled every few seconds;-- * - Live's own address / anon-number helpers (call-server, Chat's /internal/chat-effects/anon);-- * - Live's writes to data Chat caches (IP approvals, bans) send Chat a cache hint;-- * - a push a module still makes directly goes to the same ingress (stream, channel, global, all, DM,-- * disconnect, user change); one with no ingress target is dropped and logged, never synthesised or sent-- * to sockets Live does not have (TTS is spoken by Chat from the message's `tts` field).-- * A /ws/chat upgrade that still lands here is told to retry.-- */--class RemoteChatServer extends ChatServer {-- constructor() {-- super();-- this.remote = true;-- this._presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };-- this._presenceTimer = null;-- this._dropped = new Set();-- this._observeDb();-- }---- _delivery() { return require('./chat-delivery'); }---- _observeDb() {-- for (const [fn, hint] of Object.entries(OBSERVED_DB)) {-- const orig = db[fn];-- if (typeof orig !== 'function' || orig._chatObserved) continue;-- const observed = (...args) => {-- const result = orig(...args);-- try { this._delivery().invalidate(hint(args)); } catch { /* non-critical */ }-- return result;-- };-- observed._chatObserved = true;-- db[fn] = observed;-- }-- }---- async _pollPresence() {-- try {-- const p = await this._delivery().client.presence();-- if (!p) return;-- this._presence = p;-- this.slowModeByStream.clear();-- for (const [k, v] of Object.entries(p.slow_mode || {})) this.slowModeByStream.set(Number(k), Number(v) || 0);-- } catch { /* keep the last snapshot */ }-- }---- init() {-- if (this._presenceTimer) return null;-- this._pollPresence(); // floating-ok: _pollPresence catches and keeps the last snapshot-- this._presenceTimer = setInterval(() => this._pollPresence(), PRESENCE_MS);-- if (this._presenceTimer.unref) this._presenceTimer.unref();-- console.log('[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat; Live delivers through its ingress');-- return null;-- }---- handleUpgrade(req, socket) {-- // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.-- try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }-- try { socket.destroy(); } catch { /* */ }-- return true;-- }---- close() {-- if (this._presenceTimer) clearInterval(this._presenceTimer);-- this._presenceTimer = null;-- }---- // ── Pushes: Chat's ingress, or dropped and logged ──-- _drop(name) {-- if (this._dropped.has(name)) return;-- this._dropped.add(name);-- console.warn(`[Chat] ${name} has no Chat ingress target and was dropped; deliver through chat-delivery.js`);-- }---- // Guarded so a flipped CHAT_AUTHORITY can never bounce a push between this object and the seam.-- _push(name, fn) { const d = this._delivery(); return d.ingress() ? fn(d) : this._drop(name); }-- broadcastToStream(streamId, data) { return this._push('broadcastToStream', (d) => d.event({ kind: 'stream', id: streamId }, data)); }-- broadcastToChannelRoom(channelUserId, streamId, data) { return this._push('broadcastToChannelRoom', (d) => d.event({ kind: 'channel', id: channelUserId }, data)); }-- broadcastGlobal(data) { return this._push('broadcastGlobal', (d) => d.event({ kind: 'global' }, data)); }-- broadcastAll(data) { return this._push('broadcastAll', (d) => d.event({ kind: 'all' }, data)); }-- sendDm(userId, data) { return this._push('sendDm', (d) => d.event({ kind: 'user', id: userId }, data)); }-- disconnectUser(opts) { return this._push('disconnectUser', (d) => d.disconnect(opts)); }-- sendUserUpdate(userId, u) {-- const id = Number(userId) || undefined;-- const userData = u ? { id, username: u.username, display_name: u.display_name || null, role: u.role || null, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : undefined;-- return this._push('sendUserUpdate', (d) => d.invalidate({ user: id, user_data: userData }));-- }-- forwardToGlobal() { this._drop('forwardToGlobal'); }-- forwardToGlobalByChannel() { this._drop('forwardToGlobalByChannel'); }-- forwardToStreamerRooms() { this._drop('forwardToStreamerRooms'); }-- broadcastToOwnerStreams() { this._drop('broadcastToOwnerStreams'); }-- triggerChannelSound() { this._drop('triggerChannelSound'); }-- sendToConn() { this._drop('sendToConn'); }-- synthesizeAndBroadcastTTS() { this._drop('synthesizeAndBroadcastTTS'); return Promise.resolve(); }-- broadcastUserCount() { /* Chat's */ }-- broadcastUsersList() { /* Chat's */ }---- // ── Synchronous reads (presence snapshot) ──-- getTotalConnections() { return Number(this._presence.total) || 0; }-- getStreamViewerCount(streamId) { return Number((this._presence.streams || {})[streamId]) || 0; }-- getConnectedUserIp(userId) {-- const hit = (this._presence.users || []).find((u) => u.user_id === userId);-- return hit ? hit.ip : null;-- }-- findClientByAnonId(anonId, streamId) {-- // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.-- const hit = (this._presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);-- return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;-- }-- getUserList() { return { logged: [], anonCount: 0 }; }-- getAnonIdForConnection(ip, streamId = null) {-- const key = this.normalizeIp(ip);-- const hit = (this._presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));-- return hit ? hit.anon_id : this.getAnonIdForIp(key);-- }---- /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */-- async resolveAnon(ip) {-- const key = this.normalizeIp(ip);-- const num = await this._resolveUnifiedAnonNum(key);-- return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };-- }--}----module.exports = require('./chat-authority').isRemote() ? new RemoteChatServer() : new ChatServer();@@@ -30,11 +30,6 @@ const SETTING = 'deploy_last_announced';// A card covers at most 3 hours of deploys, so its time range stays readable.const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;const MAX_COMMITS = 40;--const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart----// The notice for THIS boot, kept for a while so clients that reconnect late still get it exactly once.--const REPLAY_MS = 15 * 60 * 1000;--let _live = null; // { payload, until, sent: WeakSet }const git = (args, timeout = 5000) => new Promise((resolve) => {// -c safe.directory=*: the release layout's worktrees are root-owned and the service user only reads them.@@@ -86,10 +81,10 @@ function persist(db, commits) {}/**-- * Announce this boot's new commits. Safe to call once after the chat server is up.++ * Announce this boot's new commits. Safe to call once after boot.* @returns {Promise<{ announced: number, event_id?: string|null }>}*/--async function announce({ db, chatServer, log = console }) {++async function announce({ db, log = console }) {const { head, previous, commits } = await newCommits(db);if (!head || !commits.length) return { announced: 0 };@@@ -124,30 +119,7 @@ async function announce({ db, chatServer, log = console }) {catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }if (outbox) outbox.kick();-- const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });-- const sent = new WeakSet();-- _live = { payload, until: Date.now() + REPLAY_MS, sent };-- const push = () => {-- let n = 0;-- for (const [ws] of chatServer.clients) {-- if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;-- try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }-- }-- return n;-- };-- ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${commits.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });return { announced: commits.length, event_id: eventId };}--/**-- * Called when a chat client finishes joining: if this boot shipped something and this socket has not had-- * it, send it now. Covers clients whose reconnect backoff outlasted the broadcast passes, a chat server-- * that came up late, and tabs that were asleep. The card is keyed by row id, so a repeat cannot duplicate.-- */--function replayTo(ws) {-- if (!_live || Date.now() > _live.until || _live.sent.has(ws)) return false;-- try { if (ws.readyState === 1) { ws.send(_live.payload); _live.sent.add(ws); return true; } } catch { /* socket went away */ }-- return false;--}----module.exports = { replayTo, announce, newCommits, persist, plainText, parseLog, SETTING };++module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };@@@ -270,13 +270,13 @@ effectsRouter.use((req, res, next) => {next();});--const chatServer = () => require('./chat-server');++const chatDelivery = () => require('./chat-delivery');const actorOf = (id) => (id ? db.getUserById(id) : null);effectsRouter.post('/anon', async (req, res) => {const ip = String(req.body?.ip || '');if (!ip) return fail(res, 400, 'ip required');-- try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }++ try { res.json(await chatDelivery().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }});effectsRouter.post('/ip-log', (req, res) => {@@@ -46,7 +46,7 @@ function snapshot(services = {}) {memoryMb: { rss: mb(mem.rss), heapUsed: mb(mem.heapUsed), heapTotal: mb(mem.heapTotal), external: mb(mem.external) },eventLoop: { lastMinute: lastWindow },connections: {-- chat: safe(() => services.chatServer.getTotalConnections()),++ chat: safe(() => services.chatDelivery.getTotalConnections()),broadcast: safe(() => services.broadcastServer.clients.size),call: safe(() => services.callServer.clients.size),liveEventStreams: safe(() => require('./streaming/live-events').clientCount()),@@@ -99,7 +99,7 @@ const webrtcSFU = require('./streaming/webrtc-sfu');const rtmpServer = require('./streaming/rtmp-server');// Real-time--const chatServer = require('./chat/chat-server');++const chatDelivery = require('./chat/chat-delivery');const controlServer = require('./controls/control-server');const broadcastServer = require('./streaming/broadcast-server');const callServer = require('./streaming/call-server');@@@ -198,7 +198,7 @@ const stopper = gracefulStop({try { restreamManager.stopAll(); } catch { /* */ }try { recorder.stopAll(); } catch { /* */ }try { callServer.close(); } catch { /* */ }-- try { chatServer.close(); } catch { /* */ }++ try { chatDelivery.close(); } catch { /* */ }try { controlServer.close(); } catch { /* */ }try { broadcastServer.close(); } catch { /* */ }try { jsmpegRelay.closeAll(); } catch { /* */ }@@@ -240,7 +240,7 @@ function releaseEnv() {// answers direct loopback callers only; through nginx it is a 404 (server/web/observability.js).const observability = require('./web/observability');const { registry: metricsRegistry } = observability.mountMetrics(app, { release });--const vibeCodingPublishServer = new VibeCodingPublishServer(chatServer, db);++const vibeCodingPublishServer = new VibeCodingPublishServer(db);function normalizeOrigin(origin) {if (!origin || typeof origin !== 'string') return null;@@@ -820,7 +820,7 @@ app.get('/api/health', (req, res) => {name: 'OpenVibe.Live',version: '1.0.0',uptime: process.uptime(),-- chat_connections: chatServer.getTotalConnections(),++ chat_connections: chatDelivery.getTotalConnections(),});});@@@ -859,7 +859,7 @@ app.get('/api/ready', readiness.handler);observability.registerDomainGauges(metricsRegistry, {liveStreams: () => db.getLiveStreams().length,-- wsServers: { chat: chatServer, broadcast: broadcastServer, control: controlServer, call: callServer },++ wsServers: { broadcast: broadcastServer, control: controlServer, call: callServer },outboxStatus: () => require('./events/stream-events').status(),});@@@ -927,7 +927,7 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, reurl,timestamp: new Date().toISOString(),});-- res.json({ ok: true, clients: chatServer.getTotalConnections() });++ res.json({ ok: true, clients: chatDelivery.getTotalConnections() });} catch (err) {res.status(500).json({ error: err.message });}@@@ -1139,7 +1139,7 @@ server.on('upgrade', (req, socket, head) => {// Block banned IPs from WebSocket connectionstry {-- const wsIp = chatServer.getClientIp(req);++ const wsIp = chatDelivery.getClientIp(req);if (db.isIpBanned(wsIp, null)) {// Admins pass network bans (shared home network) — see isBanExemptAdmin.let exempt = false;@@@ -1148,9 +1148,8 @@ server.on('upgrade', (req, socket, head) => {}} catch (e) { /* non-critical — allow through on DB error */ }-- if (url.startsWith('/ws/chat')) {-- chatServer.handleUpgrade(req, socket, head);-- } else if (url.startsWith('/ws/vibe-coding/publish')) {++ // /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.++ if (url.startsWith('/ws/vibe-coding/publish')) {vibeCodingPublishServer.handleUpgrade(req, socket, head);} else if (url.startsWith('/ws/broadcast')) {broadcastServer.handleUpgrade(req, socket, head);@@@ -1228,12 +1227,12 @@ async function start() {}// 3. Initialize chat server-- chatServer.init(server);++ chatDelivery.init();vibeCodingPublishServer.init(server);// 3b. Initialize breaking news serviceconst newsService = require('./news/news-service');-- newsService.setChatServer(chatServer);++ newsService.setChatServer(chatDelivery);newsService.start();// 4. Initialize control server@@@ -1461,7 +1460,7 @@ async function start() {// Deploy notice in chat: only commits not announced before, folded into one rolling message// (server/chat/deploy-notice.js). A restart with no new code says nothing.-- require('./chat/deploy-notice').announce({ db, chatServer }).catch((err) => console.warn('[Deploy notice] failed:', err.message));++ require('./chat/deploy-notice').announce({ db }).catch((err) => console.warn('[Deploy notice] failed:', err.message));});// 8. Start stale stream heartbeat cleanup (every 60 seconds)@@@ -19,7 +19,6 @@ const https = require('https');const http = require('http');const db = require('../db/database');--const chatServer = require('../chat/chat-server');const chatReads = require('../chat/chat-reads');const RECONNECT_BASE_MS = 5000;@@@ -433,16 +432,16 @@ class ChatRelayService {if (result?.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);} catch {}-- chatServer.broadcastToStream(bridge.streamId, chatMsg);++ delivery.broadcastToStream(bridge.streamId, chatMsg);this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'ai');// Also surface on the global / username-only overlay (tags stream_channel)-- try { chatServer.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }++ try { delivery.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }// And to viewers of the streamer's other live slots (cross-slot chat)-- try { chatServer.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }++ try { delivery.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTStry {-- chatServer.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`, null, chatMsg.id ? `m${chatMsg.id}` : null);++ delivery.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`, null, chatMsg.id ? `m${chatMsg.id}` : null);} catch { /* non-critical */ }this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'powerchat');@@@ -454,7 +453,7 @@ class ChatRelayService {const chatterKey = `ext:${prefixedUsername}`;if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {db.recordFirstChat(chatterKey, stream.user_id);-- chatServer.broadcastToStream(bridge.streamId, {++ delivery.broadcastToStream(bridge.streamId, {type: 'system',message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,timestamp: new Date().toISOString(),@@@ -93,15 +93,14 @@ function _testFulfillmentAllowed() {// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────// A persisted chat line plus its live card: one Chat ingress call with CHAT_AUTHORITY=chat (Chat persists and// shows it; `mirror` also puts it in global chat), else the legacy card broadcast(s) and a local save.--function _chatLine(chatServer, { frame, line, mirror = false, key }) {++function _chatLine({ frame, line, mirror = false, key }) {if (delivery.ingress()) { delivery.message({ ...line, mirror, key }); return; }-- chatServer.broadcastToChannelRoom(line.channel_user_id, line.stream_id, frame);-- if (mirror) { try { chatServer.broadcastGlobal({ ...frame, global: true, channel_user_id: line.channel_user_id }); } catch { /* */ } }++ delivery.broadcastToChannelRoom(line.channel_user_id, line.stream_id, frame);++ if (mirror) { try { delivery.broadcastGlobal({ ...frame, global: true, channel_user_id: line.channel_user_id }); } catch { /* */ } }db.saveChatMessage(line);}function _handleDonation(userId, data) {-- const chatServer = require('../chat/chat-server');const alerts = require('../monetization/alerts');const openvibeBucks = require('../monetization/vibes');@@@ -129,7 +128,7 @@ function _handleDonation(userId, data) {};const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;try {-- _chatLine(chatServer, {++ _chatLine({frame: donationEvent, mirror: true, key: eventKey,line: {stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,@@@ -141,7 +140,7 @@ function _handleDonation(userId, data) {} catch { /* */ }// 2) Donation sound.-- try { alerts.playAlertSound(chatServer, userId, streamId, 'donation'); } catch { /* */ }++ try { alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }// 3) Goal progress + 4) goal reached.if (goalResult && goalResult.goal) {@@@ -150,7 +149,7 @@ function _handleDonation(userId, data) {if (goalResult && goalResult.reached) {const g = goalResult.goal;try {-- _chatLine(chatServer, {++ _chatLine({frame: { type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts },key: eventKey && `${eventKey}:goal`,line: {@@@ -161,7 +160,7 @@ function _handleDonation(userId, data) {},});} catch { /* */ }-- try { alerts.playAlertSound(chatServer, userId, streamId, 'goal'); } catch { /* */ }++ try { alerts.playAlertSound(userId, streamId, 'goal'); } catch { /* */ }}console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);@@@ -169,11 +168,10 @@ function _handleDonation(userId, data) {// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).function _handleSubscription(userId, data) {-- const chatServer = require('../chat/chat-server');const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);const ts = new Date().toISOString();try {-- _chatLine(chatServer, {++ _chatLine({frame: { type: 'donation', username: name, amount: 0, message: 'subscribed via PowerChat', source: 'powerchat-sub', timestamp: ts },key: data.eventId ? `powerchat:${data.eventId}` : undefined,line: {@@@ -188,9 +186,8 @@ function _handleSubscription(userId, data) {// A lightweight chat notice for non-money events (follow / host / points redeem).function _handleNotice(userId, message, kind) {try {-- const chatServer = require('../chat/chat-server');-- const ts = new Date().toISOString();-- _chatLine(chatServer, {++ const ts = new Date().toISOString();++ _chatLine({frame: { type: 'system', message, source: 'powerchat', kind, timestamp: ts },line: {stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',@@@ -287,7 +284,6 @@ function processEvent(envelope) {// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;// if there's an active goal it also sends a transient goal-update preview (not persisted).function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', message = 'Test tip ✨' } = {}) {-- const chatServer = require('../chat/chat-server');const alerts = require('../monetization/alerts');// Vibes are bit-style: $1 = 100 bucks, so the test dollar amount → bucks ×100.const amount = Math.max(1, Math.round(amountUsd * 100));@@@ -305,7 +301,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag// actually test what the streamer is checking — that the alert lands in chat AND// survives a refresh. This was the only donation path that broadcast without saving.try {-- _chatLine(chatServer, {++ _chatLine({frame: testEvent,line: {stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,@@@ -315,7 +311,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag},});} catch (e) { console.warn('[PowerChat] test tip not saved to history:', e.message); }-- try { alerts.playAlertSound(chatServer, userId, streamId, 'donation'); } catch { /* */ }++ try { alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }// Transient goal-progress preview (does NOT persist — reload restores the real number).try {@@@ -3,7 +3,6 @@ const crypto = require('crypto');const WebSocket = require('ws');const db = require('../db/database');--const chatServer = require('../chat/chat-server');const chatReads = require('../chat/chat-reads');const delivery = require('../chat/chat-delivery');const { authenticateWs } = require('../auth/auth');@@@ -689,7 +688,7 @@ class RobotStreamerService {return;}try {-- chatServer.triggerChannelSound(++ delivery.triggerChannelSound(null,{ streamId: stream.id, user: null, anonId: null, ip: null },stream, scmd, parts.slice(1),@@@ -739,16 +738,16 @@ class RobotStreamerService {if (result?.lastInsertRowid) mirrored.id = Number(result.lastInsertRowid);} catch {}-- chatServer.broadcastToStream(stream.id, mirrored);++ delivery.broadcastToStream(stream.id, mirrored);rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'ai');// Also surface on the global / username-only overlay (tags stream_channel)-- try { chatServer.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }++ try { delivery.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }// And to viewers of the streamer's other live slots (cross-slot chat)-- try { chatServer.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }++ try { delivery.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }// Feed relayed RS chat into server-side TTS (same path as native chat)try {-- chatServer.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`, null, mirrored.id ? `m${mirrored.id}` : null);++ delivery.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`, null, mirrored.id ? `m${mirrored.id}` : null);} catch { /* non-critical */ }rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'powerchat');@@@ -86,7 +86,7 @@ async function _sendOne(clipId) {}const saved = db.saveChatMessage(line);payload.id = saved && saved.lastInsertRowid;-- require('../chat/chat-server').broadcastToChannelRoom(ownerId, streamId, payload);++ delivery.broadcastToChannelRoom(ownerId, streamId, payload);} catch (e) {console.warn('[ClipNotify] send failed:', e.message);}@@@ -896,10 +896,9 @@ class MediaQueue {const delivery = require('../chat/chat-delivery');// Chat's ingress fans a media frame out to the owner's live streams itself.if (delivery.ingress()) { delivery.event({ kind: 'owner-streams', id: streamerId }, payload); return; }-- const chatServer = require('../chat/chat-server');const streams = db.getLiveStreamsByUserId(streamerId) || [];for (const stream of streams) {-- chatServer.broadcastToStream(stream.id, payload);++ delivery.broadcastToStream(stream.id, payload);}} catch {// optional@@@ -12,19 +12,13 @@/*** Ask Chat to play a streamer's alert sound to their viewers (channel-wide, so it reaches every slot-- * plus the offline room). kind: 'donation' | 'goal'. No-op if Chat is not the authority or nothing is-- * configured. Chat answers `played:false` (never an error) when the channel has no sound.++ * plus the offline room). kind: 'donation' | 'goal'. Chat answers `played:false` (never an error) when++ * the channel has no sound; outside chat mode the event is dropped and logged by chat-delivery.js.*/--function playAlertSound(chatServer, streamerId, streamId, kind) {++function playAlertSound(streamerId, streamId, kind) {try {-- const delivery = require('../chat/chat-delivery');-- if (delivery.ingress()) {-- // An `alert` event on Chat's typed ingress.-- delivery.event({ kind: 'channel', id: streamerId }, { type: 'alert', streamerId: Number(streamerId), streamId: streamId ? Number(streamId) : null, kind: kind === 'goal' ? 'goal' : 'donation' });-- return;-- }-- if (!chatServer || typeof chatServer.playAlertSound !== 'function') return;-- chatServer.playAlertSound(streamerId, streamId, kind === 'goal' ? 'goal' : 'donation');++ // An `alert` event on Chat's typed ingress.++ require('../chat/chat-delivery').event({ kind: 'channel', id: streamerId }, { type: 'alert', streamerId: Number(streamerId), streamId: streamId ? Number(streamId) : null, kind: kind === 'goal' ? 'goal' : 'donation' });} catch { /* non-critical */ }}@@@ -88,7 +88,6 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {// A request the browser repeated with the same Idempotency-Key: already celebrated.if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });-- const chatServer = require('../chat/chat-server');const alerts = require('./alerts');const donorUser = db.getUserById(req.user.id);const donor = donorUser?.display_name || donorUser?.username || 'Someone';@@@ -112,15 +111,15 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {// Chat persists the line and shows it in the channel and (mirror) global chat in one call.delivery.message({ ...donationLine, mirror: true, key: result.transactionId ? `donation:${result.transactionId}` : undefined });} else {-- chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, donationEvent);++ delivery.broadcastToChannelRoom(streamer_id, stream_id || null, donationEvent);// Tips are a site-wide event worth celebrating, so mirror them into global chat// instead of confining them to the channel that received them.-- try { chatServer.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }++ try { delivery.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }try { db.saveChatMessage(donationLine); } catch { /* non-critical */ }}// 2) Donation sound (streamer-configured).-- alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');++ alerts.playAlertSound(streamer_id, stream_id, 'donation');// Mirror the donation onto the streamer's PowerChat overlay as a monetary tip// (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat@@@ -152,12 +151,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {};if (delivery.ingress()) delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });else {-- chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, {++ delivery.broadcastToChannelRoom(streamer_id, stream_id || null, {type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts,});try { db.saveChatMessage(goalLine); } catch { /* non-critical */ }}-- alerts.playAlertSound(chatServer, streamer_id, stream_id, 'goal');++ alerts.playAlertSound(streamer_id, stream_id, 'goal');}const balance = money.onBilling() ? result.balance : db.getUserById(req.user.id).openvibe_bucks_balance;@@@ -6,7 +6,7 @@** Architecture:* news-service (this) ←→ sources (reddit, newsapi, rss, ...)-- * → chatServer.broadcastToStream() / broadcastGlobal()++ * → chat-delivery.js event() (Chat's ingress)** Settings stored in SQLite: news_settings table (per-user/global).* Headline dedup via in-memory Set (cleared on restart, bounded to 500).@@@ -202,12 +202,7 @@ function announce(recap) {const g = recap.write.grade;const frame = { type: 'system', message: `📋 After-show report for "${recap.stream.title}" is in — grade ${g}: ${recap.write.headline}. Read it: /recap/${recap.stream.id}` };if (delivery.ingress()) delivery.event({ kind: 'channel', id: recap.streamer.id }, frame, { key: `recap:${recap.stream.id}` });-- else {-- const chat = require('../chat/chat-server');-- const server = chat.chatServer || chat.default || chat;-- if (!server || typeof server.broadcastToChannelRoom !== 'function') return false;-- server.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, frame);-- }++ else delivery.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, frame);db.run('UPDATE stream_recaps SET announced_at = CURRENT_TIMESTAMP WHERE stream_id = ?', [recap.stream.id]);return true;} catch (e) { console.warn('[Recap] announce:', e.message); return false; }@@@ -21,7 +21,7 @@ const WebSocket = require('ws');const db = require('../db/database');const { extractWsToken, authenticateWs } = require('../auth/auth');const permissions = require('../auth/permissions');--const chatServer = require('../chat/chat-server');++const chatDelivery = require('../chat/chat-delivery');const cosmetics = require('../monetization/cosmetics');const WS_HEARTBEAT_MS = 30000;@@@ -239,7 +239,7 @@ class CallServer {const url = new URL(req.url, 'http://localhost');const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');const token = extractWsToken(req);-- const ip = chatServer.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);++ const ip = chatDelivery.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);ws.isAlive = true;ws.on('pong', () => { ws.isAlive = true; });@@@ -263,7 +263,7 @@ class CallServer {if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }const peerId = this._generatePeerId();-- const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);++ const anonId = user ? null : chatDelivery.getAnonIdForConnection(ip, resolvedId);const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }@@@ -401,7 +401,7 @@ class CallServer {if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;}const ch = this.channels.get(channelId);-- c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);++ c.user = user; c.anonId = user ? null : chatDelivery.getAnonIdForConnection(c.ip, channelId);c.isChannelCreator = !!(user && ch?.createdBy === user.id);c.isStreamer = ch?.streamId ? !!(user && db.getStreamById(ch.streamId)?.user_id === user.id) : false;const pInfo = this._buildParticipantInfo(peerId, c);@@@ -34,7 +34,6 @@ const openreMirror = require('../openre/mirror');const i18n = require('../i18n/translate');const robotStreamerService = require('../integrations/robotstreamer-service');const chatRelayService = require('../integrations/chat-relay-service');--const chatServer = require('../chat/chat-server');// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.const moderation = require('../chat/moderation-client');const chatReads = require('../chat/chat-reads');@@@ -114,7 +114,6 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ limconst i = b.interaction || {};const userId = channelUserId(b.creator && b.creator.id);if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });-- const chatServer = require('../chat/chat-server');const streamId = streamIdFor(b.target, userId);const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);// null = the supporter hid the amount (Tips privacy.hide_amount): it stays null all the way to the chat line.@@@ -137,12 +136,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });} else {-- chatServer.broadcastToChannelRoom(userId, streamId, event);-- try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }++ delivery.broadcastToChannelRoom(userId, streamId, event);++ try { delivery.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }const saved = db.saveChatMessage(line);chatMessageId = saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null;}-- require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');++ require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));}if (b.effect === 'tts') {@@@ -156,7 +155,7 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim});return res.json(remember(key, { ok: true, ref: { stream_id: streamId, chat_message_id: id } }));}-- await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);++ await delivery.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));}if (b.effect === 'media_request') {@@@ -16,8 +16,7 @@ function hasVibeCodingPublishScope(user) {}class VibeCodingPublishServer {-- constructor(chatServer, db) {-- this.chatServer = chatServer;++ constructor(db) {this.db = db;this.wss = null;this.clients = new Map();@@@ -46,7 +46,7 @@ function mountMetrics(app, { release }) {/*** Domain gauges, read at scrape time. A source that throws is left out of that scrape.* liveStreams() -> number of live streams (the rows /api/streams lists)-- * wsServers -> { chat, broadcast, control, call } objects with a `wss` (ws.Server) once initialised++ * wsServers -> { broadcast, control, call } objects with a `wss` (ws.Server) once initialised (chat is OpenVibe.Chat's)* outboxStatus() -> server/events/stream-events.js status()*/function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {@@@ -1,31 +1,38 @@'use strict';--// server/monetization/alerts.js after roadmap T3: Live no longer reads the alert sound from disk or--// broadcasts base64 — the sound mapping lives on channel_moderation_settings, which OpenVibe.Chat--// owns — it asks Chat through the bridge op `playAlertSound [streamerId, streamId, kind]`. This--// checks the dispatcher; the op itself (resolving the sound, reading the shared file, broadcasting--// `soundboard-audio`) lives in Chat and is tested there.++// server/monetization/alerts.js after roadmap T3: the alert-sound mapping lives on++// channel_moderation_settings, which OpenVibe.Chat owns, so Live asks Chat to play the sound with an++// `alert` event on its typed ingress (server/chat/chat-delivery.js). This checks the event shape; the++// op itself (resolving the sound, reading the shared file, broadcasting `soundboard-audio`) lives in++// Chat and is tested there.//// node test/alerts-bridge.test.jsconst assert = require('assert');++const delivery = require('../server/chat/chat-delivery');const alerts = require('../server/monetization/alerts');const calls = [];--const fake = { playAlertSound: (...args) => calls.push(args) };++const realEvent = delivery.event;++delivery.event = (target, frame) => { calls.push({ target, frame }); return Promise.resolve({ ok: true }); };++try {++ alerts.playAlertSound(7, 3, 'donation');++ alerts.playAlertSound(7, 3, 'goal');++ alerts.playAlertSound(7, 3, 'anything-else');++ // Offline room: a null stream id is passed straight through (Chat reaches the channel room).++ alerts.playAlertSound(7, null, 'goal');++} finally {++ delivery.event = realEvent;++}++assert.deepStrictEqual(calls.map(({ target, frame }) => [target.kind, target.id, frame.streamerId, frame.streamId, frame.kind]), [++ ['channel', 7, 7, 3, 'donation'],++ ['channel', 7, 7, 3, 'goal'],++ ['channel', 7, 7, 3, 'donation'],++ ['channel', 7, 7, null, 'goal'],++], 'unknown kinds fall back to the donation sound, and the alert targets the channel owner');--alerts.playAlertSound(fake, 7, 3, 'donation');--alerts.playAlertSound(fake, 7, 3, 'goal');--alerts.playAlertSound(fake, 7, 3, 'anything-else');--assert.deepStrictEqual(calls, [[7, 3, 'donation'], [7, 3, 'goal'], [7, 3, 'donation']], 'unknown kinds fall back to the donation sound');++// Outside chat mode the seam drops the event (logged once), never throws into the caller.++delete process.env.CHAT_AUTHORITY;++assert.doesNotThrow(() => alerts.playAlertSound(7, 3, 'donation'));--// Offline room: a null stream id is passed straight through (Chat reaches the channel room).--alerts.playAlertSound(fake, 7, null, 'goal');--assert.deepStrictEqual(calls[3], [7, null, 'goal']);----// Live runs chat itself (rollback / dev): no Chat to ask — silent no-op, never throws.--alerts.playAlertSound({}, 7, 3, 'donation');--alerts.playAlertSound(null, 7, 3, 'donation');--assert.strictEqual(calls.length, 4, 'a chat server without the op is not called');----console.log('alerts bridge: all checks passed');++console.log('alerts event: all checks passed');@@@ -82,10 +82,9 @@ const wrap = (d) => ({JOIN users u ON shared.user_id = u.id WHERE mine.user_id = ? GROUP BY shared.user_id`).all(uid, uid),logModerationAction: () => {},};-- const chatServer = { getConnectedUserIp: () => '203.0.113.7', disconnectUser: () => {} };-- const delivery = { disconnect: () => {}, logModeration: () => {} };++ const delivery = { getConnectedUserIp: () => '203.0.113.7', disconnect: () => {}, logModeration: () => {} };// eslint-disable-next-line no-new-func-- const fn = new Function('db', 'chatServer', 'delivery', 'permissions', 'console', `${m[0]}; return performGlobalBan;`)(db, chatServer, delivery, permissions, { log() {} });++ const fn = new Function('db', 'delivery', 'permissions', 'console', `${m[0]}; return performGlobalBan;`)(db, delivery, permissions, { log() {} });let status = 200, body = null;const res = { status(c) { status = c; return this; }, json(b) { body = b; return this; } };const actor = db.getUserById(actorId);ddeleted file mode 100644@@@ -1,67 +0,0 @@--/**-- * Chat WebSocket per-address cap: one address cannot hold more than MAX_CHAT_SOCKETS_PER_IP-- * sockets, closing one frees a slot, and the count does not leak.-- *-- * node test/chat-connection-cap.test.js-- */--'use strict';--const assert = require('assert');--const fs = require('fs');--const os = require('os');--const path = require('path');--const http = require('http');----const tmp = path.join(os.tmpdir(), `ov-chatcap-${process.pid}.db`);--process.env.DB_PATH = tmp;--process.env.NODE_ENV = 'test';--const quiet = console.log;--console.log = () => {};--console.warn = () => {};----const db = require('../server/db/database');--db.initDb();--const WebSocket = require('ws');--const chat = require('../server/chat/chat-server');----const server = http.createServer();--chat.init(server);--server.on('upgrade', (req, socket, head) => { if (!chat.handleUpgrade(req, socket, head)) socket.destroy(); });----const open = (port, ip) => new Promise((resolve) => {-- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/chat`, { headers: { 'cf-connecting-ip': ip } });-- ws.on('open', () => {-- // The server closes over-cap sockets right after the upgrade; give it a tick to say so.-- const t = setTimeout(() => resolve({ ws, code: null }), 150);-- ws.on('close', (code) => { clearTimeout(t); resolve({ ws, code }); });-- });-- ws.on('error', () => resolve({ ws, code: -1 }));--});----server.listen(0, async () => {-- const port = server.address().port;-- let exit = 0;-- try {-- const held = [];-- for (let i = 0; i < 48; i++) {-- const r = await open(port, '203.0.113.7');-- assert.strictEqual(r.code, null, `socket ${i + 1} should stay open`);-- held.push(r.ws);-- }-- assert.strictEqual((await open(port, '203.0.113.7')).code, 4029, '49th socket from one address is refused');-- assert.strictEqual((await open(port, '198.51.100.9')).code, null, 'another address is unaffected');-- held.pop().close();-- await new Promise((r) => setTimeout(r, 150));-- const again = await open(port, '203.0.113.7');-- assert.strictEqual(again.code, null, 'closing a socket frees a slot');-- held.push(again.ws);-- held.forEach((w) => w.close());-- await new Promise((r) => setTimeout(r, 300));-- assert.strictEqual(chat._ipSockets.get('203.0.113.7') || 0, 0, 'count returns to zero');-- quiet('chat connection cap: all checks passed');-- } catch (e) {-- quiet('✗', e.message);-- exit = 1;-- }-- for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }-- process.exit(exit);--});@@@ -298,7 +298,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');-- // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.++ // 6. Bans: Live re-checks the moderator; the rows are exactly Live's own bans table's.const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');@@@ -383,10 +383,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));assert.strictEqual(db.getChatMessageById(900001), undefined);assert.ok(db.getUserById(viewer), 'users are never touched');-- // 10. The chat server Live's modules get (T3 J2: the bridge and its outbox are gone).-- const chatServer = require('../server/chat/chat-server');-- assert.strictEqual(chatServer.remote, true);-- chatServer.init();++ // 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its++ // outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).++ const chatDelivery = require('../server/chat/chat-delivery');++ chatDelivery.init();assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get(), 'no outbox');// Live's own writes to data Chat caches (IP approvals, bans) send Chat a cache hint.db.approveIp(channel.id, '203.0.113.7', streamer, 'manual');@@@ -394,27 +394,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));for (let i = 0; i < 100 && ingressCalls.filter((c) => c.family === 'invalidate').length < 2; i++) await sleep(20);assert.deepStrictEqual(ingressCalls.filter((c) => c.family === 'invalidate').map((c) => (c.body.bans ? 'bans' : c.body.approvals)), [channel.id, 'bans']);// Synchronous reads come from Chat's presence snapshot.-- for (let i = 0; i < 100 && chatServer.getTotalConnections() !== 7; i++) await sleep(20);-- assert.strictEqual(chatServer.getTotalConnections(), 7);-- assert.strictEqual(chatServer.getStreamViewerCount(1), 3);-- assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);-- assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');-- assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');-- assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);-- assert.strictEqual(chatServer.getAnonIdForConnection('203.0.113.9', 1), 'anon9');++ for (let i = 0; i < 100 && chatDelivery.getTotalConnections() !== 7; i++) await sleep(20);++ assert.strictEqual(chatDelivery.getTotalConnections(), 7);++ assert.strictEqual(chatDelivery.getStreamViewerCount(1), 3);++ assert.strictEqual(chatDelivery.slowModeByStream.get(1), 5000);++ assert.strictEqual(chatDelivery.getConnectedUserIp(3), '198.51.100.3');++ assert.strictEqual(chatDelivery.findClientByAnonId('anon9', 1).ip, '203.0.113.9');++ assert.strictEqual(chatDelivery.findClientByAnonId('anon9', 2), null);++ assert.strictEqual(chatDelivery.findClientByAnonId('anon9').ip, '203.0.113.9', 'no stream given: any of that anon\'s sockets');++ assert.strictEqual(chatDelivery.getAnonIdForConnection('203.0.113.9', 1), 'anon9');// Chat asks Live for anon numbers; Live answers from its own helpers.-- assert.strictEqual(typeof (await chatServer.resolveAnon('203.0.113.9')).anon_number, 'number');++ assert.strictEqual(typeof (await chatDelivery.resolveAnon('203.0.113.9')).anon_number, 'number');// !arena answers the sender in the response, after its own lookup (review 2026-10-02, PR #12).const arenaCmd = await call('POST', '/internal/chat-effects/arena-command', { token: WRITE, body: { cmd: '!arena', parts: ['!arena', 'nobody_here_at_all'], client: { conn_id: 'c1', streamId, ip: '203.0.113.30' } } });assert.strictEqual(arenaCmd.status, 200);assert.strictEqual(arenaCmd.body.handled, true);assert.strictEqual(arenaCmd.body.replies.length, 1, 'the reply made after the lookup is in the answer');assert.strictEqual(arenaCmd.body.replies[0].type, 'system');-- // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.-- let written = '';-- chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });-- assert.match(written, /^HTTP\/1\.1 503/);-- chatServer.close();++ // /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.++ chatDelivery.close();// 11. Live's chat reads (roadmap T3 J4b): stats, queues and history come from Chat's read// API through server/chat/chat-reads.js, with the caching and failure rules the moderation@@@ -117,10 +117,8 @@ function assertSigned(c, what) {const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);const delivery = require('../server/chat/chat-delivery');-- const chatServer = require('../server/chat/chat-server');-- assert.strictEqual(chatServer.remote, true);assert.strictEqual(delivery.ingress(), true, 'CHAT_AUTHORITY=chat is ingress, with no flag');-- chatServer.init();++ delivery.init();delivery.client._setRetryMs([20, 20]);let exit = 0;@@@ -206,7 +204,7 @@ function assertSigned(c, what) {// 5b. A news headline: one keyed /events card to the stream.const news = require('../server/news/news-service');-- news._chatServer = chatServer;++ news._chatServer = delivery;news._getActiveStreamIds = () => [streamId];news.isEnabledForStream = () => true;news._pendingQueue.push({ headline: 'Big news', sourceId: 'test' });@@@ -218,7 +216,7 @@ function assertSigned(c, what) {// 6. Deploy notice: no ingress endpoint, Events off → left unannounced (next boot retries), never bridged.const before = db.getSetting('deploy_last_announced');-- const dn = await require('../server/chat/deploy-notice').announce({ db, chatServer, log: { warn() {}, log() {}, info() {} } });++ const dn = await require('../server/chat/deploy-notice').announce({ db, log: { warn() {}, log() {}, info() {} } });assert.strictEqual(dn.announced, 0);assert.strictEqual(db.getSetting('deploy_last_announced'), before, 'not recorded as announced');@@@ -229,8 +227,8 @@ function assertSigned(c, what) {assertSigned(await waitFor(() => find('invalidate', (b) => b.user === viewer), 'user hint'), 'user hint');// 8. Presence comes from /internal/chat/presence.-- await waitFor(() => chatServer.getTotalConnections() === 4, 'presence snapshot');-- assert.strictEqual(chatServer.getConnectedUserIp(99), '198.51.100.9');++ await waitFor(() => delivery.getTotalConnections() === 4, 'presence snapshot');++ assert.strictEqual(delivery.getConnectedUserIp(99), '198.51.100.9');// 9. A Chat 5xx: retried with the same key, then given up; never thrown, never bridged.fail.events = { status: 503, times: 1 };@@@ -264,12 +262,12 @@ function assertSigned(c, what) {const saved = {};for (const k of synth) { saved[k] = tts[k]; tts[k] = (...a) => { touched.push(k); return saved[k](...a); }; }const n = calls.length;-- chatServer.broadcastToStream(streamId, { type: 'system', message: 'direct' });-- chatServer.sendDm(viewer, { type: 'dm-notice' });-- chatServer.disconnectUser({ userId: viewer, streamId });-- chatServer.sendUserUpdate(viewer, { username: 'viewer', display_name: 'VIEWER', password_hash: 'never' });-- chatServer.forwardToGlobal(streamId, { type: 'chat', message: 'no target' });-- await chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, 'm1');++ delivery.broadcastToStream(streamId, { type: 'system', message: 'direct' });++ delivery.sendDm(viewer, { type: 'dm-notice' });++ delivery.disconnectUser({ userId: viewer, streamId });++ delivery.sendUserUpdate(viewer, { username: 'viewer', display_name: 'VIEWER', password_hash: 'never' });++ delivery.forwardToGlobal(streamId, { type: 'chat', message: 'no target' });++ await delivery.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, 'm1');for (const k of synth) tts[k] = saved[k];assert.deepStrictEqual(touched, [], 'no TTS synthesis in Live');await waitFor(() => calls.length >= n + 4, 'direct pushes');@@@ -279,9 +277,9 @@ function assertSigned(c, what) {// CHAT_AUTHORITY unset under a RemoteChatServer (a test or a bad env edit): dropped, never bounced back.process.env.CHAT_AUTHORITY = '';assert.strictEqual(delivery.ingress(), false);-- assert.strictEqual(chatServer.broadcastToStream(streamId, { type: 'system', message: 'x' }), undefined);++ assert.strictEqual(delivery.broadcastToStream(streamId, { type: 'system', message: 'x' }), undefined);process.env.CHAT_AUTHORITY = 'chat';-- chatServer.close();++ delivery.close();quiet('chat ingress (CHAT_AUTHORITY=chat): all checks passed');} catch (err) {@@@ -1,3 +1,8 @@++'use strict';++// Live no longer runs a chat server: DM delivery is OpenVibe.Chat's (chat-delivery.js only carries++// the seam). server/chat/dm.js stays for its tables and the participant/block helpers Live's own++// routes still use — server/index.js ensureTables() and server/streaming/routes.js (call blocking by++// isBlockedEither). This checks those helpers.const assert = require('assert');const os = require('os');const path = require('path');@@@ -8,7 +13,6 @@ process.env.DB_PATH = tempDbPath;const db = require('../server/db/database');const dm = require('../server/chat/dm');--const chatServer = require('../server/chat/chat-server');async function run() {try {@@@ -28,36 +32,16 @@ async function run() {assert(dm.isParticipant(convId, userC), 'userC should be participant');assert(!dm.isParticipant(convId, userD), 'userD should not be a participant');-- const sent = [];-- const makeWs = (label, userId) => ({-- readyState: 1,-- bufferedAmount: 0,-- send(payload) {-- sent.push({ label, payload, userId });-- },-- });++ // The call routes use these to refuse a ring between users either of whom blocked the other.++ assert.strictEqual(dm.isBlockedEither(userA, userD), false);++ dm.blockUser(userD, userA);++ assert(dm.isBlockedEither(userA, userD), 'a block in either direction counts');++ assert(dm.hasBlocked(userD, userA));++ assert(!dm.hasBlocked(userA, userD), 'blocking stays one-directional in the row');++ dm.unblockUser(userD, userA);++ assert.strictEqual(dm.isBlockedEither(userA, userD), false);-- const wsA1 = makeWs('A1', userA);-- const wsA2 = makeWs('A2', userA);-- const wsB = makeWs('B', userB);-- const wsD = makeWs('D', userD);---- chatServer.clients.set(wsA1, { user: { id: userA, username: 'accountA' } });-- chatServer.clients.set(wsA2, { user: { id: userA, username: 'accountA' } });-- chatServer.clients.set(wsB, { user: { id: userB, username: 'accountB' } });-- chatServer.clients.set(wsD, { user: { id: userD, username: 'accountD' } });---- chatServer.sendDm(userA, { type: 'dm', conversation_id: convId, message: { id: 1, text: 'hello A' } });-- assert.strictEqual(sent.filter(x => x.label.startsWith('A')).length, 2, 'userA sockets should receive DM');---- chatServer.sendDm(userB, { type: 'dm', conversation_id: convId, message: { id: 2, text: 'hello B' } });-- assert.strictEqual(sent.filter(x => x.label === 'B').length, 1, 'userB should receive DM');---- const beforeCount = sent.length;-- chatServer.sendDm(userD, { type: 'dm', conversation_id: convId, message: { id: 3, text: 'should not deliver' } });-- assert.strictEqual(sent.length, beforeCount, 'non-participant should not receive DM');---- console.log('✅ DM delivery regression test passed');++ console.log('✅ DM helpers (participants, blocks) regression test passed');} finally {try { db.close(); } catch {};try { fs.unlinkSync(tempDbPath); } catch {};@@@ -65,6 +49,6 @@ async function run() {}run().catch((err) => {-- console.error('DM delivery test failed:', err);++ console.error('DM helpers test failed:', err);process.exit(1);});@@@ -23,7 +23,6 @@ function stub(modPath, exportsObj) {stub(path.join(__dirname, '../server/db/database'), {});stub(path.join(__dirname, '../server/auth/auth'), { authenticateWs: () => null, extractWsToken: () => null });--stub(path.join(__dirname, '../server/chat/chat-server'), { _broadcastMessage: () => {} });const controlServer = require('../server/controls/control-server');const chatRelay = require('../server/integrations/chat-relay-service');@@@ -58,7 +58,7 @@ const stub = http.createServer((req, res) => {assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);// Events off: the notice works exactly as before and nothing is queued.-- let r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });++ let r = await dn.announce({ db, log: quiet });assert.ok(r.announced >= 1);assert.strictEqual(r.event_id, null);assert.strictEqual(db.getSetting(dn.SETTING), head);@@@ -68,7 +68,7 @@ const stub = http.createServer((req, res) => {const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });assert.ok(outbox);db.setSetting(dn.SETTING, '');-- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });++ r = await dn.announce({ db, log: quiet });assert.ok(r.announced >= 1);assert.match(r.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);let rows = outboxRows();@@@ -86,7 +86,7 @@ const stub = http.createServer((req, res) => {assert.strictEqual(sent.event_type, 'live.release.deployed');// A restart with no new code: no notice, no event.-- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });++ r = await dn.announce({ db, log: quiet });assert.strictEqual(r.announced, 0);assert.strictEqual(outboxRows().length, 1);@@@ -95,7 +95,7 @@ const stub = http.createServer((req, res) => {db.setSetting(dn.SETTING, '');const chatBefore = deployRows().map(x => x.metadata).join('|');raw.exec("CREATE TEMP TRIGGER outbox_boom BEFORE INSERT ON event_outbox BEGIN SELECT RAISE(ABORT, 'outbox insert failed'); END");-- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });++ r = await dn.announce({ db, log: quiet });raw.exec('DROP TRIGGER temp.outbox_boom');assert.strictEqual(r.announced, 0);assert.strictEqual(db.getSetting(dn.SETTING), '');@@@ -104,7 +104,7 @@ const stub = http.createServer((req, res) => {// CHAT_AUTHORITY=chat with Events on: Chat learns the deploy from the event alone (C-84).process.env.CHAT_AUTHORITY = 'chat';-- r = await dn.announce({ db, chatServer: { remote: true }, log: quiet });++ r = await dn.announce({ db, log: quiet });assert.ok(r.announced >= 1);assert.strictEqual(db.getSetting(dn.SETTING), head);rows = outboxRows();@@@ -116,7 +116,7 @@ const stub = http.createServer((req, res) => {streamEvents._reset();process.env.EVENTS_PUBLISH = 'off';db.setSetting(dn.SETTING, '');-- r = await dn.announce({ db, chatServer: { remote: true }, log: quiet });++ r = await dn.announce({ db, log: quiet });delete process.env.EVENTS_PUBLISH;delete process.env.CHAT_AUTHORITY;assert.strictEqual(r.announced, 0);@@@ -231,21 +231,6 @@ const ok = (name) => { pass++; console.log(' ok -', name); };ok('media quote refuses internal addresses (all IPv6 spellings) and non-http schemes');}--// ── A ban has to stop commands too, not just plain messages ──────────────────--{-- const src = fs.readFileSync(path.join(ROOT, 'server/chat/chat-server.js'), 'utf8');-- const fnStart = src.indexOf('handleChatMessage(ws, client, msg) {');-- assert(fnStart > 0, 'handleChatMessage should exist');-- const body = src.slice(fnStart, fnStart + 3000);-- const banAt = body.indexOf('db.isUserBanned(client.user.id, modStreamId)');-- const bangAt = body.indexOf("text.startsWith('!')");-- const slashAt = body.indexOf("text.startsWith('/')");-- assert(banAt > 0 && bangAt > 0 && slashAt > 0, 'all three branches should be present');-- assert(banAt < bangAt && banAt < slashAt,-- 'the ban check must run before the "!" and "/" command dispatch, which both return early');-- ok('banned users cannot run ! or / chat commands');--}--// ── A private VOD's context must be authorised before the cache is consulted ──{const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');@@@ -349,7 +349,7 @@ async function check(name, fn) {'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)','server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)','server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',-- 'server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)',++ 'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)','server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)','server/streaming/golive-notify.js': 'OpenVibe.Network (configured)','server/utils/notify.js': 'OpenVibe.Network (configured)',@@@ -62,7 +62,6 @@ assert.deepStrictEqual(p.getCapabilities(null).staff_caps, []);const TARGET_RANK = [['internal/routes.js', "user.is_owner && role !== 'admin'"],['chat/live-context-routes.js', "target.role === 'admin'"],-- ['chat/chat-server.js', "targetUser.role === 'admin'"],['admin/routes.js', "target.role === 'admin'"],['admin/routes.js', "banTarget.role === 'admin'"],['admin/routes.js', "if (user.role === 'admin') return res.status(400)"],@@@ -38,12 +38,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ issd.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);-- const chatServer = require('../server/chat/chat-server');++ const delivery = require('../server/chat/chat-delivery');const broadcasts = [];-- chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });-- chatServer.broadcastGlobal = () => {};++ delivery.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });++ delivery.broadcastGlobal = () => {};const tts = [];-- chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };++ delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };const app = express();app.use('/internal/tips', require('../server/tips/delivery-routes'));@@@ -54,7 +54,7 @@ function connect(port, token) {assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');// The real WebSocket endpoint with real hbt_ tokens.-- const pub = new VibeCodingPublishServer({ broadcastToStream() {} }, db);++ const pub = new VibeCodingPublishServer(db);const server = http.createServer();pub.init(server);server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });
@@ -49,7 +49,6 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -49,7 +49,6 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
- **Files and restore drills:** every file location comes from [server/paths.js](server/paths.js) (`DATA_DIR`, `DB_PATH`), never a literal `./data`. Anything started at boot or at module load (timers, listeners, sockets, jobs, outbound calls) must stay off under `LIVE_DRILL` ([server/drill.js](server/drill.js)); `test/drill-mode.test.js` fails on any new one.
- **Files and restore drills:** every file location comes from [server/paths.js](server/paths.js) (`DATA_DIR`, `DB_PATH`), never a literal `./data`. Anything started at boot or at module load (timers, listeners, sockets, jobs, outbound calls) must stay off under `LIVE_DRILL` ([server/drill.js](server/drill.js)); `test/drill-mode.test.js` fails on any new one.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **WebSocket servers:** Each has `init(server)` and `handleUpgrade(req, socket, head)` methods.
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
- **Frontend globals:** `currentUser`, `api()`, `navigate()`, `handleLinkClick()`. Cross-component sync via `CustomEvent` (e.g., `openvibe-auth-changed`).
-- **ChatServer:** Singleton — `chat-server.js` exports `new ChatServer()`, not the class.
## Key Pitfalls
## Key Pitfalls
@@ -64,7 +63,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
@@ -64,7 +63,7 @@ Each feature lives in its own `server/<feature>/` directory with `routes.js` + s
## WebSocket Endpoints
## WebSocket Endpoints
-`/ws/chat`, `/ws/broadcast`, `/ws/control`, `/ws/call`, `/ws/robotstreamer-publish` — all upgraded via handler in `server/index.js` with origin checks and IP bans.
+`/ws/broadcast`, `/ws/control`, `/ws/call`, `/ws/robotstreamer-publish` — upgraded via the handler in `server/index.js` with origin checks and IP bans. `/ws/chat` is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live runs no chat WebSocket server.
## Testing
## Testing
@@ -134,11 +134,13 @@ Recording, VODs and clips are produced by OpenVibe.Media from the live session
@@ -134,11 +134,13 @@ Recording, VODs and clips are produced by OpenVibe.Media from the live session
## Chat
## Chat
```
```
-Client WS → chat-server.js → SQLite (saveChatMessage) → room broadcast (stream / channel / global)
+Client WS → OpenVibe.Chat (127.0.0.1:4400; nginx routes /ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds there)
```
```
-The room is derived from the stream the client joined; offline channel chat applies the channel's
-bans and chat rules. Reconnects are jittered; a `server_restart` notice shows "OpenVibe is updating"
-and refills history on reconnect.
+Live runs no chat WebSocket server or chat REST routes: it delivers to Chat's typed ingress through
+`server/chat/chat-delivery.js` (producer pushes, moderation, cache hints) and reads Chat's snapshot for
+presence. The room is derived from the stream the client joined; offline channel chat applies the
+channel's bans and chat rules. Reconnects are jittered; a `server_restart` notice shows "OpenVibe is
+updating" and refills history on reconnect. See [chat-system.md](chat-system.md).
## Server runtime
## Server runtime
@@ -154,7 +156,7 @@ and refills history on reconnect.
@@ -154,7 +156,7 @@ and refills history on reconnect.
| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines |
| Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines |
| Database | `server/db/database.js` | queries and inline table setup |
| Database | `server/db/database.js` | queries and inline table setup |
| Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |
| Migrations | `server/db/migrations.js` | versioned, transactional migrations with a ledger (`schema_migrations`) |
-| Chat | `server/chat/chat-server.js` | WebSocket chat, history, moderation (REST: OpenVibe.Chat) |
+| Chat delivery | `server/chat/chat-delivery.js` | Live's one seam to OpenVibe.Chat (pushes, moderation, cache hints, presence) |
| Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages |
| Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages |
| Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media |
| Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |
| Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks |
@@ -4,18 +4,19 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
@@ -4,18 +4,19 @@ The OpenVibe.Live chat system provides real-time messaging, moderation, and exte
## Architecture
## Architecture
-- **WebSocket server**: `server/chat/chat-server.js` — manages connections, rooms, and message routing
+- **WebSocket server**: OpenVibe.Chat (nginx sends `/ws/chat` there) — Live runs no chat server of its own
- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.
- **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired.
- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
- **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync
-### OpenVibe.Chat (roadmap Wave 6)
+### OpenVibe.Chat (roadmap Wave 6; live since 2026-09-23)
-Chat is moving to [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400)
-with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches it on
-(`server/chat/chat-authority.js`); without it nothing here changes.
+Chat runs in [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400) with the
+same WebSocket protocol and REST paths. Live runs no chat server in any mode (`CHAT_AUTHORITY=chat`
+in production, `server/chat/chat-authority.js`); the local `ChatServer` and its `/ws/chat` upgrade
+path are deleted.
-- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live answers
- them with 503 if one still arrives, and `/ws/chat` upgrades are refused.
+- nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live mounts
+ none of them, and a `/ws/chat` upgrade that still lands on Live is destroyed.
- Live's chat producers (AI viewer lines, relayed and RobotStreamer chat, donations and alert sounds,
- Live's chat producers (AI viewer lines, relayed and RobotStreamer chat, donations and alert sounds,
cards, `/api/mod` deletes and moderation, cache hints) go through one seam,
cards, `/api/mod` deletes and moderation, cache hints) go through one seam,
[server/chat/chat-delivery.js](../server/chat/chat-delivery.js), to Chat's typed service-token ingress
[server/chat/chat-delivery.js](../server/chat/chat-delivery.js), to Chat's typed service-token ingress
@@ -23,11 +24,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
@@ -23,11 +24,13 @@ with the same WebSocket protocol and REST paths. `CHAT_AUTHORITY=chat` switches
`GET /internal/chat/presence`). Chat persists, broadcasts and speaks (`tts`) in one call per
`GET /internal/chat/presence`). Chat persists, broadcasts and speaks (`tts`) in one call per
operation; each carries one idempotency key reused on every retry. A Chat 4xx is logged and dropped; a 5xx
operation; each carries one idempotency key reused on every retry. A Chat 4xx is logged and dropped; a 5xx
or timeout is retried past Chat's five-minute delivery lease, then logged and dropped; neither is thrown.
or timeout is retried past Chat's five-minute delivery lease, then logged and dropped; neither is thrown.
-- `require('./chat/chat-server')` returns a `RemoteChatServer` that never listens: synchronous reads
- (`getTotalConnections`, viewer counts, slow modes, a connected user's IP) come from Chat's presence
- snapshot, polled every 3 s; Live's IP-approval and ban writes send Chat a cache hint; a push a module still
- makes on it goes to the same ingress, and TTS is never synthesised in Live. The arena commands
- (`/internal/chat-effects/arena-command`) answer the sender in the response's `replies`.
+- `server/chat/chat-delivery.js` also carries the surface Live's remaining modules used on the old
+ chat server: the push names (`broadcastToStream`, `broadcastToChannelRoom`, `sendDm`, …) go to the
+ same ingress; synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's
+ IP, anon ids) come from Chat's presence snapshot, polled every 3 s by `init()`; Live's IP-approval and
+ ban writes send Chat a cache hint; a push with no ingress target (`forwardToGlobal`,
+ `synthesizeAndBroadcastTTS`, …) is dropped and logged once, and TTS is never synthesised in Live. The
+ arena commands (`/internal/chat-effects/arena-command`) answer the sender in the response's `replies`.
- Deploy notices reach Chat only as the `live.release.deployed` event: with Events publishing off the commits
- Deploy notices reach Chat only as the `live.release.deployed` event: with Events publishing off the commits
stay unannounced and the next boot tries again.
stay unannounced and the next boot tries again.
- **The old ordered-calls bridge is gone** (T3 J2): no `chat-remote.js`, no `POST /internal/live/calls`, no
- **The old ordered-calls bridge is gone** (T3 J2): no `chat-remote.js`, no `POST /internal/live/calls`, no
@@ -30,7 +30,6 @@ const express = require('express');
@@ -30,7 +30,6 @@ const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
-const chatServer = require('../chat/chat-server');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
const ipUtils = require('./ip-utils');
const ipUtils = require('./ip-utils');
@@ -110,7 +109,7 @@ function performGlobalBan(req, res, { userId, reason, durationHours, ipAddress }
@@ -110,7 +109,7 @@ function performGlobalBan(req, res, { userId, reason, durationHours, ipAddress }
: null;
: null;
// Auto-detect IP from connected WebSocket clients, then fall back to IP log
// Auto-detect IP from connected WebSocket clients, then fall back to IP log
- let resolvedIp = ipAddress || chatServer.getConnectedUserIp(targetUser.id);
+ let resolvedIp = ipAddress || delivery.getConnectedUserIp(targetUser.id);
if (!resolvedIp) {
if (!resolvedIp) {
const latest = db.getLatestIpForUser(targetUser.id);
const latest = db.getLatestIpForUser(targetUser.id);
if (latest) resolvedIp = latest.ip_address;
if (latest) resolvedIp = latest.ip_address;
@@ -234,7 +233,7 @@ router.post('/stream-ban', async (req, res) => {
@@ -234,7 +233,7 @@ router.post('/stream-ban', async (req, res) => {
if (!targetUser) return res.status(404).json({ error: 'User not found' });
if (!targetUser) return res.status(404).json({ error: 'User not found' });
// Auto-detect IP from connected clients if not provided
// Auto-detect IP from connected clients if not provided
- const resolvedIp = ip_address || chatServer.getConnectedUserIp(targetUser.id);
+ const resolvedIp = ip_address || delivery.getConnectedUserIp(targetUser.id);
db.run(
db.run(
`INSERT INTO bans (stream_id, user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
`INSERT INTO bans (stream_id, user_id, ip_address, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?, ?)`,
@@ -256,7 +255,7 @@ router.post('/stream-ban', async (req, res) => {
@@ -256,7 +255,7 @@ router.post('/stream-ban', async (req, res) => {
res.json({ message: `${targetUser.username} banned from stream` });
res.json({ message: `${targetUser.username} banned from stream` });
} else {
} else {
// Anon ban — look up IP from connected anon client
// Anon ban — look up IP from connected anon client
- const anonClient = chatServer.findClientByAnonId(anon_id, streamId);
+ const anonClient = delivery.findClientByAnonId(anon_id, streamId);
const resolvedIp = ip_address || anonClient?.ip || null;
const resolvedIp = ip_address || anonClient?.ip || null;
db.run(
db.run(
@@ -418,11 +417,11 @@ router.post('/delete-message', async (req, res) => {
@@ -418,11 +417,11 @@ router.post('/delete-message', async (req, res) => {
const delPayload = { type: 'delete-messages', ids: [parseInt(message_id)] };
const delPayload = { type: 'delete-messages', ids: [parseInt(message_id)] };
const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);
const chanUid = message.channel_user_id || (message.stream_id ? (db.getStreamById(message.stream_id)?.user_id || null) : null);
if (delivery.ingress()) { /* broadcast by Chat */ } else if (chanUid || message.stream_id) {
if (delivery.ingress()) { /* broadcast by Chat */ } else if (chanUid || message.stream_id) {
- chatServer.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);
- if (message.stream_id) chatServer.forwardToGlobal(message.stream_id, delPayload);
- else chatServer.broadcastGlobal(delPayload);
+ delivery.broadcastToChannelRoom(chanUid, message.stream_id, delPayload);
+ if (message.stream_id) delivery.forwardToGlobal(message.stream_id, delPayload);
+ else delivery.broadcastGlobal(delPayload);
} else {
} else {
- chatServer.broadcastGlobal(delPayload);
+ delivery.broadcastGlobal(delPayload);
}
}
delivery.logModeration({
delivery.logModeration({
@@ -486,14 +485,11 @@ router.post('/delete-user-messages', async (req, res) => {
@@ -486,14 +485,11 @@ router.post('/delete-user-messages', async (req, res) => {
const deleteMsg = { type: 'delete-messages', ids };
const deleteMsg = { type: 'delete-messages', ids };
if (scopedStreamId) {
if (scopedStreamId) {
const chanUid = db.getStreamById(scopedStreamId)?.user_id || null;
const chanUid = db.getStreamById(scopedStreamId)?.user_id || null;
- chatServer.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);
- chatServer.forwardToGlobal(scopedStreamId, deleteMsg);
+ delivery.broadcastToChannelRoom(chanUid, scopedStreamId, deleteMsg);
+ delivery.forwardToGlobal(scopedStreamId, deleteMsg);
} else {
} else {
- // Global deletion — broadcast to every connected client
- const msg = JSON.stringify(deleteMsg);
- for (const [ws] of chatServer.clients) {
- if (ws.readyState === 1) ws.send(msg);
- }
+ // Global deletion — Chat fans it out to the clients it holds; Live has none.
+ delivery.broadcastGlobal(deleteMsg);
}
}
}
}
@@ -701,8 +697,8 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
@@ -701,8 +697,8 @@ router.post('/ip-approval/:channelId/approve', async (req, res) => {
timestamp: msg.created_at,
timestamp: msg.created_at,
was_held: true,
was_held: true,
};
};
- chatServer.broadcastToStream(msg.stream_id, chatMsg);
- chatServer.forwardToGlobal(msg.stream_id, chatMsg);
+ delivery.broadcastToStream(msg.stream_id, chatMsg);
+ delivery.forwardToGlobal(msg.stream_id, chatMsg);
}
}
}
}
@@ -802,7 +798,7 @@ router.get('/ip/user/:userId', permissions.requireGlobalMod, (req, res) => {
@@ -802,7 +798,7 @@ router.get('/ip/user/:userId', permissions.requireGlobalMod, (req, res) => {
const linked = db.getLinkedAccounts(userId);
const linked = db.getLinkedAccounts(userId);
// Also check if the user is currently connected and get their live IP
// Also check if the user is currently connected and get their live IP
- const liveIp = chatServer.getConnectedUserIp(userId);
+ const liveIp = delivery.getConnectedUserIp(userId);
res.json({
res.json({
user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, is_banned: user.is_banned, ban_reason: user.ban_reason, created_at: user.created_at },
user: { id: user.id, username: user.username, display_name: user.display_name, role: user.role, is_banned: user.is_banned, ban_reason: user.ban_reason, created_at: user.created_at },
@@ -824,7 +820,7 @@ router.get('/ip/anon/:anonId', permissions.requireGlobalMod, (req, res) => {
@@ -824,7 +820,7 @@ router.get('/ip/anon/:anonId', permissions.requireGlobalMod, (req, res) => {
const linked = db.getLinkedAccountsByAnon(anonId);
const linked = db.getLinkedAccountsByAnon(anonId);
// Try to find their live IP from connected clients
// Try to find their live IP from connected clients
- const anonClient = chatServer.findClientByAnonId(anonId);
+ const anonClient = delivery.findClientByAnonId(anonId);
const liveIp = anonClient?.ip || null;
const liveIp = anonClient?.ip || null;
const currentIp = liveIp || latest?.ip_address || null;
const currentIp = liveIp || latest?.ip_address || null;
@@ -42,7 +42,6 @@ const path = require('path');
@@ -42,7 +42,6 @@ const path = require('path');
const { execSync } = require('child_process');
const { execSync } = require('child_process');
const db = require('../db/database');
const db = require('../db/database');
const { requireAuth } = require('../auth/auth');
const { requireAuth } = require('../auth/auth');
-const chatServer = require('../chat/chat-server');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
@@ -65,7 +64,7 @@ router.get('/diagnostics', (req, res) => {
@@ -65,7 +64,7 @@ router.get('/diagnostics', (req, res) => {
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json(require('../diagnostics').snapshot({
res.json(require('../diagnostics').snapshot({
db,
db,
- chatServer: require('../chat/chat-server'),
+ chatDelivery: require('../chat/chat-delivery'),
broadcastServer: require('../streaming/broadcast-server'),
broadcastServer: require('../streaming/broadcast-server'),
callServer: (() => { try { return require('../streaming/call-server'); } catch { return null; } })(),
callServer: (() => { try { return require('../streaming/call-server'); } catch { return null; } })(),
restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),
restreamManager: (() => { try { return require('../streaming/restream-manager'); } catch { return null; } })(),
@@ -253,7 +252,7 @@ router.put('/users/:id', (req, res) => {
@@ -253,7 +252,7 @@ router.put('/users/:id', (req, res) => {
delivery.invalidate({
delivery.invalidate({
user: id,
user: id,
user_data: { id, username: safeUser.username, display_name: safeUser.display_name || null, role: safeUser.role || null, avatar_url: safeUser.avatar_url || null, profile_color: safeUser.profile_color || null },
user_data: { id, username: safeUser.username, display_name: safeUser.display_name || null, role: safeUser.role || null, avatar_url: safeUser.avatar_url || null, profile_color: safeUser.profile_color || null },
- }, () => chatServer.sendUserUpdate(id, safeUser));
+ });
}
}
res.json({ user: safeUser });
res.json({ user: safeUser });
@@ -227,7 +227,7 @@ function threadsBlock(channelUserId, limit) {
@@ -227,7 +227,7 @@ function threadsBlock(channelUserId, limit) {
}
}
function viewerCountOf(stream) {
function viewerCountOf(stream) {
- try { const cs = require('../../chat/chat-server'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }
+ try { const cs = require('../../chat/chat-delivery'); if (cs && typeof cs.getStreamViewerCount === 'function') return cs.getStreamViewerCount(stream.id); } catch { /* */ }
return stream && stream.viewer_count != null ? stream.viewer_count : null;
return stream && stream.viewer_count != null ? stream.viewer_count : null;
}
}
@@ -85,10 +85,9 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})
@@ -85,10 +85,9 @@ function post(worker, bot, message, { threadId = null, replyToId = null } = {})
filtered: false, timestamp: new Date().toISOString(),
filtered: false, timestamp: new Date().toISOString(),
};
};
try {
try {
- const chatServer = require('../../chat/chat-server');
- chatServer.broadcastToStream(streamId, chatMsg);
- chatServer.forwardToGlobal(streamId, chatMsg);
- if (ttsOn) chatServer.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
+ delivery.broadcastToStream(streamId, chatMsg);
+ delivery.forwardToGlobal(streamId, chatMsg);
+ if (ttsOn) delivery.synthesizeAndBroadcastTTS(streamId, bot.username, message, null, null, `aibot:${bot.username.toLowerCase()}`, null, id ? `m${id}` : null);
} catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
} catch (e) { console.warn('[AI-Viewers] broadcast failed:', e.message); }
forwardToPowerChat(worker, bot, message, id);
forwardToPowerChat(worker, bot, message, id);
@@ -50,7 +50,7 @@ class Scheduler {
@@ -50,7 +50,7 @@ class Scheduler {
linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }
linesLastMinute() { const cut = Date.now() - 60000; return this.recent.filter(r => r.at > cut).length; }
_slowModeMs() {
_slowModeMs() {
- try { const cs = require('../../chat/chat-server'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }
+ try { const cs = require('../../chat/chat-delivery'); return cs.slowModeByStream ? (cs.slowModeByStream.get(this.worker.streamId) || 0) : 0; } catch { return 0; }
}
}
_arm(delay = null) {
_arm(delay = null) {
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read
* voice.js — "hear it in their voice": Arena lines (taunts, quotes, headlines, ringside calls) read
* out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the
* out in the streamer's own OpenVibe chat TTS voice — the cosmetic voice they equipped, else the
- * per-identity auto voice chat already gives them (same `user:<username>` key as chat-server).
+ * per-identity auto voice chat already gives them (same `user:<username>` key OpenVibe.Chat uses).
*
*
* Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),
* Every (voice, text) pair is synthesized ONCE and kept on disk (`data/tts-cache/<hash>.<ext>`),
* served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply
* served with a week-long HTTP cache — repeat clicks cost nothing, and a changed voice simply
@@ -6,14 +6,16 @@
@@ -6,14 +6,16 @@
* (nginx routes them there) and owns the chat tables. Live then
* (nginx routes them there) and owns the chat tables. Live then
* - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;
* - does not start its chat WebSocket server or mount /api/chat, /api/dm, /api/tts, /api/sounds;
* - delivers every chat call its other modules make through Chat's typed ingress
* - delivers every chat call its other modules make through Chat's typed ingress
- * (server/chat/chat-delivery.js → chat-client.js); require('./chat/chat-server') returns a
- * RemoteChatServer that reads Chat's presence and never listens;
+ * (server/chat/chat-delivery.js → chat-client.js), which also carries the push names, the
+ * presence reads and the address/anon helpers Live's modules used on its old chat server — Live
+ * runs no chat server in any mode;
* - reads chat stats, queues and history from Chat's internal read API
* - reads chat stats, queues and history from Chat's internal read API
* (server/chat/chat-reads.js → chat-client.js), and still keeps its chat tables as a read
* (server/chat/chat-reads.js → chat-client.js), and still keeps its chat tables as a read
* mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved
* mirror Chat writes (POST /internal/chat-effects/mirror) so the readers that have not moved
* yet keep working and a rollback loses nothing;
* yet keep working and a rollback loses nothing;
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
* - answers Chat's reads and side effects on /internal/chat-context/* and /internal/chat-effects/*.
- * Anything else (default): Live runs chat itself, exactly as before.
+ * Anything else (default): Live runs no chat server any more (chat-server.js is gone), so chat is inert: every push
+ * is dropped and logged once by chat-delivery.js. Unsetting CHAT_AUTHORITY is therefore no rollback lever.
*/
*/
const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');
const CHAT_URL = (process.env.OV_CHAT_INTERNAL_URL || 'http://127.0.0.1:4400').replace(/\/+$/, '');
@@ -1,18 +1,17 @@
@@ -1,18 +1,17 @@
'use strict';
'use strict';
/**
/**
- * The one seam Live's chat producers call (T3 J2).
+ * The one seam Live's chat producers call (T3 J2; Live's own ChatServer is deleted).
*
*
* CHAT_AUTHORITY=chat: OpenVibe.Chat's typed ingress (chat-client.js) — Chat persists, broadcasts, mirrors,
* CHAT_AUTHORITY=chat: OpenVibe.Chat's typed ingress (chat-client.js) — Chat persists, broadcasts, mirrors,
* speaks and moderates in one call per operation. This is the only path to Chat: the old ordered-calls bridge
* speaks and moderates in one call per operation. This is the only path to Chat: the old ordered-calls bridge
- * and its outbox are gone.
- * Otherwise (rollback / dev): Live's own ChatServer — require('./chat-server').
+ * and its outbox are gone, and Live no longer runs a chat server in any mode. This module also carries the
+ * ChatServer surface Live's remaining modules still use.
*
*
- * A Chat 4xx/5xx is logged and counted by chat-client.js and never thrown into the caller. Every function
- * here returns a promise in ingress mode and the local call's own value otherwise.
+ * A Chat 4xx/5xx is logged and counted by chat-client.js and never thrown into the caller.
*
*
* ingress() which path is active (CHAT_AUTHORITY=chat)
* ingress() which path is active (CHAT_AUTHORITY=chat)
* message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>
* message(body) ingress only: a chat line (Chat's /messages body) → Promise<real id | null>
- * event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user, id, stream }
+ * event(target, frame, opts) a transient frame; target { kind: stream|channel|global|all|user | Chat's own kinds }
* moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>
* moderate(action, fields) ingress only: Chat's /moderation actions → Promise<Chat's answer | null>
* logModeration(entry) a moderation-log row (db.logModerationAction shape)
* logModeration(entry) a moderation-log row (db.logModerationAction shape)
* disconnect({ userId, ip, streamId })
* disconnect({ userId, ip, streamId })
@@ -20,13 +19,24 @@
@@ -20,13 +19,24 @@
* mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP
* mirror(fn, ...args) ingress only: keep Live's own copy of a write Chat also applies (pending IP
* rows, hidden relay users, first chats, TTS overrides) that Live still reads
* rows, hidden relay users, first chats, TTS overrides) that Live still reads
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
* after(value, fn) fn(value) now for a plain value, after it resolves for a promise
+ *
+ * The pushes Live's modules used to make on the chat server keep their names here — broadcastToStream,
+ * broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate, disconnectUser — and go
+ * to the same ingress. A push Chat's ingress has no target for (forwardToGlobal, forwardToStreamerRooms,
+ * triggerChannelSound, synthesizeAndBroadcastTTS — Chat speaks a message's `tts` field and plays channel
+ * sounds from its own settings) is dropped and logged once, exactly as the old RemoteChatServer did.
+ *
+ * Synchronous reads came from Chat's presence snapshot (GET /internal/chat/presence), polled every few
+ * seconds by init()/close() (called from server/index.js at boot and drain): connection and viewer counts,
+ * slow modes, a connected user's address. Live's own address/anon helpers live here too (call-server,
+ * Chat's /internal/chat-effects/anon), along with Live's writes to data Chat caches (IP approvals, bans)
+ * which send Chat a cache hint instead of waiting for its cache to expire.
*/
*/
+const db = require('../db/database');
const chatAuthority = require('./chat-authority');
const chatAuthority = require('./chat-authority');
const client = require('./chat-client');
const client = require('./chat-client');
function ingress() { return chatAuthority.isRemote(); }
function ingress() { return chatAuthority.isRemote(); }
-// Lazy: chat-server pulls in most of Live, and several producers are required by it.
-function chatServer() { return require('./chat-server'); }
function defined(o) {
function defined(o) {
const out = {};
const out = {};
@@ -50,19 +60,19 @@ function message(body) {
@@ -50,19 +60,19 @@ function message(body) {
return client.message(b).then((r) => (r && r.id != null ? Number(r.id) : null));
return client.message(b).then((r) => (r && r.id != null ? Number(r.id) : null));
}
}
+// Pushes with no Chat ingress target: logged once per name, never synthesised.
+const _dropped = new Set();
+function dropped(name, why = 'has no Chat ingress target and was dropped') {
+ if (_dropped.has(name)) return;
+ _dropped.add(name);
+ console.warn(`[Chat] ${name} ${why}`);
+}
+
function event(target, frame, { key } = {}) {
function event(target, frame, { key } = {}) {
- if (ingress()) {
- const t = { kind: target.kind };
- if (target.id != null) t.id = Number(target.id);
- return client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });
- }
- const cs = chatServer();
- if (target.kind === 'stream') return cs.broadcastToStream(target.id, frame);
- if (target.kind === 'channel') return cs.broadcastToChannelRoom(target.id, target.stream || null, frame);
- if (target.kind === 'global') return cs.broadcastGlobal(frame);
- if (target.kind === 'all') return cs.broadcastAll(frame);
- if (target.kind === 'user') return cs.sendDm(target.id, frame);
- throw new Error(`unknown chat target ${target.kind}`);
+ if (!ingress()) { dropped(`event(${target.kind})`, 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }
+ const t = { kind: target.kind };
+ if (target.id != null) t.id = Number(target.id);
+ return client.event({ key: key ? client.key('events', key) : undefined, target: t, frame: defined(frame) });
}
}
function moderate(action, fields, { key } = {}) {
function moderate(action, fields, { key } = {}) {
@@ -71,7 +81,7 @@ function moderate(action, fields, { key } = {}) {
@@ -71,7 +81,7 @@ function moderate(action, fields, { key } = {}) {
const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];
const LOG_FIELDS = ['scope_type', 'scope_id', 'actor_user_id', 'target_user_id', 'action_type', 'details'];
function logModeration(entry) {
function logModeration(entry) {
- if (!ingress()) return require('../db/database').logModerationAction(entry);
+ if (!ingress()) return db.logModerationAction(entry);
const fields = {};
const fields = {};
for (const k of LOG_FIELDS) if (entry[k] != null) fields[k] = entry[k];
for (const k of LOG_FIELDS) if (entry[k] != null) fields[k] = entry[k];
if (typeof fields.scope_id === 'string' && fields.scope_type !== 'room' && /^\d+$/.test(fields.scope_id)) fields.scope_id = Number(fields.scope_id);
if (typeof fields.scope_id === 'string' && fields.scope_type !== 'room' && /^\d+$/.test(fields.scope_id)) fields.scope_id = Number(fields.scope_id);
@@ -79,9 +89,10 @@ function logModeration(entry) {
@@ -79,9 +89,10 @@ function logModeration(entry) {
}
}
function disconnect({ userId, ip, streamId } = {}) {
function disconnect({ userId, ip, streamId } = {}) {
- if (!ingress()) return chatServer().disconnectUser(defined({ userId, ip, streamId }));
+ if (!ingress()) { dropped('disconnectUser', 'dropped — Live runs no chat server (CHAT_AUTHORITY is not chat)'); return undefined; }
return moderate('disconnect', { user_id: userId || undefined, ip: ip || undefined, stream_id: streamId || undefined });
return moderate('disconnect', { user_id: userId || undefined, ip: ip || undefined, stream_id: streamId || undefined });
}
}
+const disconnectUser = disconnect;
/** legacy: what to run when Live is the chat authority (cache hints had no single local call). */
/** legacy: what to run when Live is the chat authority (cache hints had no single local call). */
function invalidate(hint, legacy) {
function invalidate(hint, legacy) {
@@ -100,7 +111,7 @@ const MIRROR_WRITES = new Set([
@@ -100,7 +111,7 @@ const MIRROR_WRITES = new Set([
function mirror(fn, ...args) {
function mirror(fn, ...args) {
try {
try {
if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');
if (!MIRROR_WRITES.has(fn)) throw new Error('not a mirror write');
- return require('../db/database')[fn](...args);
+ return db[fn](...args);
} catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }
} catch (err) { console.warn(`[ChatIngress] local ${fn}: ${err.message}`); return undefined; }
}
}
@@ -108,4 +119,215 @@ function after(value, fn) {
@@ -108,4 +119,215 @@ function after(value, fn) {
return value && typeof value.then === 'function' ? value.then(fn) : fn(value);
return value && typeof value.then === 'function' ? value.then(fn) : fn(value);
}
}
-module.exports = { ingress, message, event, moderate, logModeration, disconnect, invalidate, mirror, after, client, MIRROR_WRITES };
+// ── The pushes modules still make on the chat server (names kept from ChatServer) ─────────────
+function broadcastToStream(streamId, frame) { return event({ kind: 'stream', id: streamId }, frame); }
+function broadcastToChannelRoom(channelUserId, streamId, frame) { return event({ kind: 'channel', id: channelUserId }, frame); }
+function broadcastGlobal(frame) { return event({ kind: 'global' }, frame); }
+function broadcastAll(frame) { return event({ kind: 'all' }, frame); }
+function sendDm(userId, frame) { return event({ kind: 'user', id: userId }, frame); }
+function sendUserUpdate(userId, u) {
+ const id = Number(userId) || undefined;
+ const userData = u ? { id, username: u.username, display_name: u.display_name || null, role: u.role || null, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : undefined;
+ return invalidate({ user: id, user_data: userData });
+}
+// No ingress target — the old ChatServer forwarded these to Live's own other rooms/slots; Chat fans a
+// channel event out to the channel's rooms itself, and speaks/sounds from the message's own fields.
+function forwardToGlobal() { dropped('forwardToGlobal'); }
+function forwardToStreamerRooms() { dropped('forwardToStreamerRooms'); }
+function triggerChannelSound() { dropped('triggerChannelSound'); }
+function synthesizeAndBroadcastTTS() { dropped('synthesizeAndBroadcastTTS'); return Promise.resolve(); }
+
+// ── Presence: Chat's snapshot, polled (the synchronous reads health, diagnostics and moderation use) ──
+const OBSERVED_DB = {
+ approveIp: (a) => ({ approvals: Number(a[0]) || undefined }),
+ revokeIpApproval: (a) => ({ approvals: Number(a[0]) || undefined }),
+ forgiveBan: () => ({ bans: true }),
+};
+const PRESENCE_MS = 3000;
+const slowModeByStream = new Map();
+let _presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };
+let _presenceTimer = null;
+let _dbObserved = false;
+
+// Live-owned data Chat caches, written by Live's own routes (IP approvals, bans): after the write Chat is
+// told to reload it instead of waiting for its cache to expire.
+function _observeDb() {
+ for (const [fn, hint] of Object.entries(OBSERVED_DB)) {
+ const orig = db[fn];
+ if (typeof orig !== 'function' || orig._chatObserved) continue;
+ const observed = (...args) => {
+ const result = orig(...args);
+ try { invalidate(hint(args)); } catch { /* non-critical */ }
+ return result;
+ };
+ observed._chatObserved = true;
+ db[fn] = observed;
+ }
+}
+
+async function _pollPresence() {
+ try {
+ const p = await client.presence();
+ if (!p) return;
+ _presence = p;
+ slowModeByStream.clear();
+ for (const [k, v] of Object.entries(p.slow_mode || {})) slowModeByStream.set(Number(k), Number(v) || 0);
+ } catch { /* keep the last snapshot */ }
+}
+
+/** Start the presence poll (and the cache-hint observers). Called once at startup; no-op outside chat mode. */
+function init() {
+ if (!ingress()) return null;
+ if (!_dbObserved) { _dbObserved = true; _observeDb(); }
+ if (_presenceTimer) return null;
+ _pollPresence(); // floating-ok: _pollPresence catches and keeps the last snapshot
+ _presenceTimer = setInterval(() => _pollPresence(), PRESENCE_MS);
+ if (_presenceTimer.unref) _presenceTimer.unref();
+ console.log('[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat; Live delivers through its ingress');
+ return null;
+}
+
+function close() {
+ if (_presenceTimer) clearInterval(_presenceTimer);
+ _presenceTimer = null;
+}
+
+function getTotalConnections() { return Number(_presence.total) || 0; }
+function getStreamViewerCount(streamId) { return Number((_presence.streams || {})[streamId]) || 0; }
+function getConnectedUserIp(userId) {
+ const hit = (_presence.users || []).find((u) => u.user_id === userId);
+ return hit ? hit.ip : null;
+}
+function findClientByAnonId(anonId, streamId) {
+ // The socket's stream must equal streamId; with no streamId given (a moderator looking an anon up by id alone),
+ // any of that anon's sockets answers.
+ const hit = (_presence.anons || []).find((a) => a.anon_id === anonId && (streamId === undefined || (a.stream_id ?? null) === streamId));
+ return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
+}
+
+// ── Live's own address / anon helpers (Chat's /internal/chat-effects/anon, call-server) ──
+const _openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
+const anonMap = new Map(); // IP → number
+const pendingResolves = new Map(); // IP → pending resolve promise (dedup concurrent)
+let nextAnonId = 1;
+let _anonDbLoaded = false;
+
+function normalizeIp(ip) {
+ let normalized = String(ip || 'unknown').trim();
+ if (!normalized) normalized = 'unknown';
+ if (normalized === '::1') return '127.0.0.1';
+ if (normalized.startsWith('::ffff:')) return normalized.slice(7);
+ return normalized;
+}
+
+/**
+ * Extract the real client IP from Express/WS request.
+ * Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
+ * then X-Forwarded-For first entry, then socket remote address.
+ */
+function getClientIp(req) {
+ const raw = req.headers?.['cf-connecting-ip']
+ || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
+ || req.socket?.remoteAddress
+ || req.connection?.remoteAddress
+ || 'unknown';
+ return normalizeIp(raw);
+}
+
+/** Warm the in-memory anonMap from DB on first use, so anon numbers survive server restarts. */
+function _loadAnonMappings() {
+ if (_anonDbLoaded) return;
+ _anonDbLoaded = true;
+ try {
+ const { maxNum, mappings } = db.loadAnonMappings();
+ for (const [ip, num] of mappings) anonMap.set(ip, num);
+ nextAnonId = maxNum + 1;
+ if (mappings.size > 0) {
+ console.log(`[Chat] Loaded ${mappings.size} persistent anon mappings (next: anon${nextAnonId})`);
+ }
+ } catch (e) {
+ console.warn('[Chat] Failed to load anon mappings from DB:', e.message);
+ }
+}
+
+/** Resolve anon number from openvibe.network unified API. Returns a Promise<number>; falls back to local DB. */
+async function _resolveUnifiedAnonNum(ip) {
+ if (anonMap.has(ip)) return anonMap.get(ip);
+ if (pendingResolves.has(ip)) return pendingResolves.get(ip);
+
+ const promise = (async () => {
+ try {
+ const auth = await require('../net/network-principal').headersFor('/internal/resolve-anon');
+ const res = await fetch(`${_openvibeToolsUrl}/internal/resolve-anon`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...auth },
+ body: JSON.stringify({ ip }),
+ });
+ if (res.ok) {
+ const data = await res.json();
+ const num = data.anon_number;
+ anonMap.set(ip, num);
+ if (num >= nextAnonId) nextAnonId = num + 1;
+ try { db.getOrCreateAnonNum(ip); } catch { /* ok */ } // warmup cache
+ return num;
+ }
+ throw new Error(`HTTP ${res.status}`);
+ } catch (e) {
+ console.warn(`[Chat] Unified anon resolve failed for ${ip}, falling back to local:`, e.message);
+ try {
+ const num = db.getOrCreateAnonNum(ip);
+ anonMap.set(ip, num);
+ if (num >= nextAnonId) nextAnonId = num + 1;
+ return num;
+ } catch {
+ const num = nextAnonId++;
+ anonMap.set(ip, num);
+ return num;
+ }
+ } finally {
+ pendingResolves.delete(ip);
+ }
+ })();
+
+ pendingResolves.set(ip, promise);
+ return promise;
+}
+
+function getAnonIdForIp(ip) {
+ _loadAnonMappings();
+ const anonKey = normalizeIp(ip);
+ if (anonMap.has(anonKey)) return `anon${anonMap.get(anonKey)}`;
+ // Synchronous fallback for immediate use — kick off the unified resolve in the background.
+ _resolveUnifiedAnonNum(anonKey).catch(() => {});
+ try {
+ const num = db.getOrCreateAnonNum(anonKey);
+ anonMap.set(anonKey, num);
+ if (num >= nextAnonId) nextAnonId = num + 1;
+ return `anon${num}`;
+ } catch {
+ const num = nextAnonId++;
+ anonMap.set(anonKey, num);
+ return `anon${num}`;
+ }
+}
+
+function getAnonIdForConnection(ip, streamId = null) {
+ const key = normalizeIp(ip);
+ const hit = (_presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));
+ return hit ? hit.anon_id : getAnonIdForIp(key);
+}
+
+/** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */
+async function resolveAnon(ip) {
+ const key = normalizeIp(ip);
+ const num = await _resolveUnifiedAnonNum(key);
+ return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };
+}
+
+module.exports = {
+ ingress, message, event, moderate, logModeration, disconnect, disconnectUser, invalidate, mirror, after, client, MIRROR_WRITES,
+ broadcastToStream, broadcastToChannelRoom, broadcastGlobal, broadcastAll, sendDm, sendUserUpdate,
+ forwardToGlobal, forwardToStreamerRooms, triggerChannelSound, synthesizeAndBroadcastTTS,
+ init, close, slowModeByStream, getTotalConnections, getStreamViewerCount, getConnectedUserIp, findClientByAnonId,
+ normalizeIp, getClientIp, getAnonIdForIp, getAnonIdForConnection, resolveAnon,
+};
deleted file mode 100644
deleted file mode 100644
@@ -1,2648 +0,0 @@
@@ -1,2648 +0,0 @@
-/**
- * OpenVibe.Live — WebSocket Chat Server
- *
- * Features:
- * - Anonymous chat with sequential numbering (anon12345)
- * - Global chat + per-stream chat
- * - Word filtering (safe/unsafe mode)
- * - Anti-VPN approval queue
- * - Streamer moderation (ban, timeout, delete)
- * - Chat commands (/help, /tts, /color, etc.)
- * - Rate limiting
- */
-const WebSocket = require('ws');
-const fs = require('fs');
-const path = require('path');
-const { v4: uuidv4 } = require('uuid');
-const db = require('../db/database');
-const { extractWsToken, authenticateWs } = require('../auth/auth');
-const permissions = require('../auth/permissions');
-const moderation = require('./moderation-client');
-const wordFilter = require('./word-filter');
-const cosmetics = require('../monetization/cosmetics');
-const ttsEngine = require('./tts-engine');
-const soundboard = require('./soundboard-service');
-const dm = require('./dm');
-const ipUtils = require('../admin/ip-utils');
-// Concurrent chat sockets per address. Generous: one person has several tabs, a school or
-// carrier NAT puts many people behind one address; this only stops one host opening thousands.
-const MAX_CHAT_SOCKETS_PER_IP = 48;
-
-const DEBUG_DM_DELIVERY = process.env.DEBUG_DM_DELIVERY === '1';
-
-const WS_HEARTBEAT_MS = 30000;
-const CHAT_AUTO_DELETE_SWEEP_MS = 30000;
-const MIN_CHAT_AUTO_DELETE_MINUTES = 3;
-const MAX_CHAT_AUTO_DELETE_MINUTES = 10080;
-const MAX_SEND_BACKPRESSURE = 256 * 1024;
-const RATE_LIMIT_CACHE_TTL_MS = 10 * 60 * 1000;
-const SOUNDBOARD_RATE_LIMIT_MS = 8000;
-const SOUNDBOARD_STREAM_MAX_PER_WINDOW = 15;
-const SOUNDBOARD_STREAM_WINDOW_MS = 60 * 1000;
-const GOTTI_GIF_URL = 'https://media1.tenor.com/m/Y-GsLUQT9LQAAAAd/deez-something-came-in-the-mail-today.gif';
-const GOTTI_CAPTION = 'Something came in the mail today... deez nuts. GOTTI!';
-const DEFAULT_SLUR_NUDGE = 'This streamer enabled Anti-Slur Nudge for this chat. Free speech is still alive, but this lane is closed today. Try a different word and keep it funny.';
-// Built-in slur categories and normalization are defined in moderation-utils.js
-// (single source of truth — browser-side chat.js mirrors the same patterns).
-const {
- CORE_SLUR_CATEGORIES,
- normalizeSlurText: _modNormalizeSlurText,
- normalizeSlurPatternText: _modNormalizeSlurPatternText,
- containsCoreSlur: _modContainsCoreSlur,
- containsRegexSlur: _modContainsRegexSlur,
- containsConfiguredSlur: _modContainsConfiguredSlur,
- compileRegexList: _modCompileRegexList,
-} = require('./moderation-utils');
-
-class ChatServer {
- constructor() {
- this.wss = null;
- /** @type {Map<WebSocket, { user: object|null, anonId: string, streamId: number|null, ip: string }>} */
- this.clients = new Map();
- /** @type {Map<string, number>} ip → open sockets */
- this._ipSockets = new Map();
- /** @type {Map<string, number>} IP → unified anon number (warm cache, backed by openvibe.network) */
- this.anonMap = new Map();
- this.nextAnonId = 1;
- this._anonDbLoaded = false;
- /** @type {Map<string, number>} `${ip}:${streamId}` → last message time (rate limiting) */
- this.rateLimits = new Map();
- this.DEFAULT_RATE_LIMIT_MS = 1000; // 1 message per second
- /** @type {Map<number, number>} streamId → slow mode ms (0 = off, default rate limit applies) */
- this.slowModeByStream = new Map();
- this.heartbeatInterval = null;
- /** @type {Map<number, number>} streamId → current TTS queue size */
- this.ttsQueueSize = new Map();
- /** @type {Map<string, number>} `${streamId}:${userId}` → user's TTS queue count */
- this.ttsUserCounts = new Map();
- /** @type {Map<string, number>} `${streamId}:${userKey}` → last soundboard trigger */
- this.soundboardRateLimits = new Map();
- /** @type {Map<number, {count: number, windowStart: number}>} streamId → stream-level soundboard rate window */
- this.soundboardStreamLimits = new Map();
- this._autoDeleteSweepInterval = null;
-
- // ── Unified anon resolution via openvibe.network internal API ──
- this._openvibeToolsUrl = process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:3100';
- /** @type {Map<string, Promise<number>>} IP → pending resolve promise (dedup concurrent) */
- this._pendingResolves = new Map();
- }
-
- normalizeIp(ip) {
- let normalized = String(ip || 'unknown').trim();
- if (!normalized) normalized = 'unknown';
- if (normalized === '::1') return '127.0.0.1';
- if (normalized.startsWith('::ffff:')) return normalized.slice(7);
- return normalized;
- }
-
- /**
- * Extract the real client IP from Express/WS request.
- * Prefers CF-Connecting-IP (set by Cloudflare, unforgeable through proxy),
- * then X-Forwarded-For first entry, then socket remote address.
- */
- /** Admins (the site owner) pass IP / network bans — they may share a home network with a banned person. */
- /**
- * Is this viewer new to OpenVibe.Live? True for an account under a day old — unless the same
- * address has had an anon identity for longer — and for an anon first seen under a day ago.
- * Drives the default of the viewer-side "Friendly global chat" setting only.
- */
- _isNewcomer(client) {
- const DAY = 24 * 3600 * 1000;
- const age = (ts) => { if (!ts) return null; const t = Date.parse(String(ts).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(ts)) ? '' : 'Z')); return Number.isFinite(t) ? Date.now() - t : null; };
- let anonAge = null;
- try { anonAge = age(db.getAnonFirstSeen(this.normalizeIp(client.ip))); } catch { /* */ }
- if (client.user) {
- const userAge = age(client.user.created_at);
- if (userAge == null || userAge >= DAY) return false;
- return !(anonAge != null && anonAge >= DAY);
- }
- return anonAge == null ? false : anonAge < DAY;
- }
-
- _isBanExemptAdmin(client) { return !!(client && client.user && !client.user.is_banned && permissions.can(client.user, 'staff.limits.exempt')); }
-
- getClientIp(req) {
- const raw = req.headers?.['cf-connecting-ip']
- || req.headers?.['x-forwarded-for']?.split(',')[0]?.trim()
- || req.socket?.remoteAddress
- || req.connection?.remoteAddress
- || 'unknown';
- return this.normalizeIp(raw);
- }
-
- /**
- * Warm the in-memory anonMap from DB on first use.
- * This ensures anon numbers survive server restarts.
- */
- _loadAnonMappings() {
- if (this._anonDbLoaded) return;
- this._anonDbLoaded = true;
- try {
- const { maxNum, mappings } = db.loadAnonMappings();
- for (const [ip, num] of mappings) {
- this.anonMap.set(ip, num);
- }
- this.nextAnonId = maxNum + 1;
- if (mappings.size > 0) {
- console.log(`[Chat] Loaded ${mappings.size} persistent anon mappings (next: anon${this.nextAnonId})`);
- }
- } catch (e) {
- console.warn('[Chat] Failed to load anon mappings from DB:', e.message);
- }
- }
-
- /**
- * Resolve anon number from openvibe.network unified API.
- * Returns a Promise<number>. Caches in memory and falls back to local DB.
- */
- async _resolveUnifiedAnonNum(ip) {
- // Already cached
- if (this.anonMap.has(ip)) return this.anonMap.get(ip);
-
- // Dedup concurrent resolves for the same IP
- if (this._pendingResolves.has(ip)) return this._pendingResolves.get(ip);
-
- const promise = (async () => {
- try {
- const auth = await require('../net/network-principal').headersFor('/internal/resolve-anon');
- const res = await fetch(`${this._openvibeToolsUrl}/internal/resolve-anon`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- ...auth,
- },
- body: JSON.stringify({ ip }),
- });
- if (res.ok) {
- const data = await res.json();
- const num = data.anon_number;
- this.anonMap.set(ip, num);
- if (num >= this.nextAnonId) this.nextAnonId = num + 1;
- // Also sync to local DB for warmup cache
- try { db.getOrCreateAnonNum(ip); } catch { /* ok */ }
- return num;
- }
- throw new Error(`HTTP ${res.status}`);
- } catch (e) {
- console.warn(`[Chat] Unified anon resolve failed for ${ip}, falling back to local:`, e.message);
- // Fallback to local DB
- try {
- const num = db.getOrCreateAnonNum(ip);
- this.anonMap.set(ip, num);
- if (num >= this.nextAnonId) this.nextAnonId = num + 1;
- return num;
- } catch {
- const num = this.nextAnonId++;
- this.anonMap.set(ip, num);
- return num;
- }
- } finally {
- this._pendingResolves.delete(ip);
- }
- })();
-
- this._pendingResolves.set(ip, promise);
- return promise;
- }
-
- getAnonIdForIp(ip) {
- this._loadAnonMappings();
- const anonKey = this.normalizeIp(ip);
- if (this.anonMap.has(anonKey)) {
- return `anon${this.anonMap.get(anonKey)}`;
- }
- // Synchronous fallback for immediate use — kick off async resolve in background
- this._resolveUnifiedAnonNum(anonKey).catch(() => {});
- // Use local DB for synchronous path
- try {
- const num = db.getOrCreateAnonNum(anonKey);
- this.anonMap.set(anonKey, num);
- if (num >= this.nextAnonId) this.nextAnonId = num + 1;
- return `anon${num}`;
- } catch (e) {
- // Fallback: use in-memory only
- const num = this.nextAnonId++;
- this.anonMap.set(anonKey, num);
- return `anon${num}`;
- }
- }
-
- getAnonIdForConnection(ip, streamId = null) {
- const anonKey = this.normalizeIp(ip);
- for (const [, info] of this.clients) {
- if (info.ip !== anonKey || !info.anonId) continue;
- if (streamId == null || info.streamId === streamId) {
- return info.anonId;
- }
- }
- return this.getAnonIdForIp(anonKey);
- }
-
- /**
- * Attach to an existing HTTP server for WebSocket upgrade
- */
- init(server) {
- this.wss = new WebSocket.Server({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
-
- // Word filter
- wordFilter.load();
-
- if (this.heartbeatInterval) clearInterval(this.heartbeatInterval);
- this.heartbeatInterval = setInterval(() => {
- if (!this.wss) return;
-
- const now = Date.now();
- for (const [ip, lastSeen] of this.rateLimits.entries()) {
- if ((now - lastSeen) > RATE_LIMIT_CACHE_TTL_MS) {
- this.rateLimits.delete(ip);
- }
- }
-
- this.wss.clients.forEach((ws) => {
- if (ws.isAlive === false) {
- try { ws.terminate(); } catch {}
- return;
- }
- ws.isAlive = false;
- try { ws.ping(); } catch {}
- });
- }, WS_HEARTBEAT_MS);
-
- // ── Viewer snapshot recording (every 60s) ────────────
- if (this._snapshotInterval) clearInterval(this._snapshotInterval);
- this._snapshotInterval = setInterval(() => {
- this._recordViewerSnapshots();
- }, 60_000);
-
- if (this._autoDeleteSweepInterval) clearInterval(this._autoDeleteSweepInterval);
- this._autoDeleteSweepInterval = setInterval(() => {
- this._sweepExpiredChatMessages();
- }, CHAT_AUTO_DELETE_SWEEP_MS);
- this._sweepExpiredChatMessages();
-
- this.wss.on('connection', (ws, req) => {
- this.handleConnection(ws, req);
- });
-
- console.log('[Chat] WebSocket chat server initialized');
- return this.wss;
- }
-
- /**
- * Handle WebSocket upgrade for chat connections
- */
- handleUpgrade(req, socket, head) {
- if (req.url.startsWith('/ws/chat')) {
- this.wss.handleUpgrade(req, socket, head, (ws) => {
- this.wss.emit('connection', ws, req);
- });
- return true;
- }
- return false;
- }
-
- /**
- * Handle a new chat connection
- */
- handleConnection(ws, req) {
- const ip = this.getClientIp(req);
-
- // Diagnostic: log IP resolution chain for first few connections
- if (this.anonMap.size < 20) {
- console.log('[Chat] IP resolution — cf-connecting-ip:', req.headers?.['cf-connecting-ip'] || '(none)',
- '| x-forwarded-for:', req.headers?.['x-forwarded-for'] || '(none)',
- '| x-real-ip:', req.headers?.['x-real-ip'] || '(none)',
- '| socket:', req.socket?.remoteAddress || '(none)',
- '| resolved:', ip);
- }
-
- const urlParams = new URL(req.url, 'http://localhost').searchParams;
- const token = extractWsToken(req);
- const streamId = parseInt(urlParams.get('stream')) || null;
-
- ws.isAlive = true;
- ws.on('pong', () => {
- ws.isAlive = true;
- });
- try { ws._socket?.setNoDelay(true); } catch {}
-
- // Authenticate (optional — anon if no token)
- const user = authenticateWs(token);
-
- const perIp = this._ipSockets.get(ip) || 0;
- if (ip && ip !== 'unknown' && perIp >= MAX_CHAT_SOCKETS_PER_IP && !permissions.can(user, 'staff.limits.exempt')) {
- ws.close(4029, 'Too many connections');
- return;
- }
- this._ipSockets.set(ip, perIp + 1);
- let released = false;
- const release = () => {
- if (released) return;
- released = true;
- const n = (this._ipSockets.get(ip) || 1) - 1;
- if (n > 0) this._ipSockets.set(ip, n); else this._ipSockets.delete(ip);
- };
-
- // Generate or reuse anon ID for this IP
- const anonId = user ? null : this.getAnonIdForConnection(ip, streamId);
-
- const clientInfo = {
- user,
- anonId,
- streamId,
- ip,
- joinedAt: Date.now(),
- };
- this.clients.set(ws, clientInfo);
-
- // Log IP for tracking
- try {
- const geo = ipUtils.enrichIp(ip);
- db.logIp({ userId: user?.id, anonId, ip, action: 'chat', geo });
- } catch (e) { /* non-critical */ }
-
- // (No verbose welcome message — the client shows a concise "Connected to
- // chat" / "Chatting as X" instead.)
-
- // Send user count + users list update
- this.broadcastUserCount(streamId);
- this.broadcastUsersList(streamId);
-
- // ── Message handler ──────────────────────────────────
- ws.on('message', (data) => {
- try {
- const msg = JSON.parse(data.toString());
- this.handleMessage(ws, msg);
- } catch (err) {
- console.warn('[Chat] Malformed message from', ws._clientIp || 'unknown', ':', err.message);
- }
- });
-
- ws.on('close', () => {
- release();
- this.clients.delete(ws);
- this.broadcastUserCount(streamId);
- this.broadcastUsersList(streamId);
- });
-
- ws.on('error', (err) => {
- console.warn('[Chat] WebSocket error for', ws._clientIp || 'unknown', ':', err.message);
- release();
- this.clients.delete(ws);
- });
- }
-
- /**
- * Handle incoming chat message
- */
- handleMessage(ws, msg) {
- const client = this.clients.get(ws);
- if (!client) return;
-
- // Rate limiting (only for chat messages, not join/leave)
- if (msg.type === 'chat') {
- const now = Date.now();
- const rateKey = `${client.ip}:${client.streamId || 'global'}`;
- const lastMsg = this.rateLimits.get(rateKey) || 0;
- const streamSlowMs = this.slowModeByStream.get(client.streamId) || 0;
- const effectiveLimit = Math.max(this.DEFAULT_RATE_LIMIT_MS, streamSlowMs);
- if (now - lastMsg < effectiveLimit) {
- this.sendTo(ws, { type: 'system', message: 'Slow down! You are sending messages too fast.' });
- return;
- }
- this.rateLimits.set(rateKey, now);
- }
-
- switch (msg.type) {
- case 'chat':
- this.handleChatMessage(ws, client, msg);
- break;
- case 'self-delete-history':
- this.handleSelfDeleteHistory(ws, client);
- break;
- case 'join':
- case 'join_stream': {
- // (Re-)authenticate if a token is provided
- if (msg.token) {
- const user = authenticateWs(msg.token);
- if (user) {
- if (!client.user || client.user.id === user.id) {
- client.user = user;
- client.anonId = null; // no longer anonymous
- } else {
- console.warn(`[Chat] Ignoring token identity mismatch for ${client.user.username} -> ${user.username}`);
- }
- }
- }
- const oldStream = client.streamId;
- client.streamId = parseInt(msg.streamId || msg.stream_id) || null;
- // Channel room: the streamer's stable user id. Lets a viewer stay in
- // the SAME chat room across live-slot switches AND while the streamer
- // is offline. Prefer an explicit channelUserId from the client; else
- // derive it from the live stream's owner.
- let channelUserId = parseInt(msg.channelUserId || msg.channel_user_id) || null;
- // With a stream, the room is that stream's owner — whatever the client says. Taking the
- // client's value let a viewer join stream A's session while posting into channel B's
- // room, where B's bans and chat rules (checked against the stream) never applied.
- if (client.streamId) {
- channelUserId = null;
- try { const s = db.getStreamById(client.streamId); if (s) channelUserId = s.user_id; } catch { /* ignore */ }
- }
- client.channelUserId = channelUserId;
- client._modStream = null;
- // Update viewer counts for old and new streams
- if (oldStream !== client.streamId) {
- if (oldStream) this.broadcastUserCount(oldStream);
- this.broadcastUserCount(client.streamId);
- }
- // A deploy notice from this boot reaches late joiners too (once per socket; see deploy-notice.js).
- setTimeout(() => { try { require('./deploy-notice').replayTo(ws); } catch { /* optional */ } }, 1200);
- // Send identity confirmation so the client knows who it is
- const displayName = client.user ? (client.user.display_name || client.user.username) : client.anonId;
- const streamSlowSec = client.streamId ? Math.round((this.slowModeByStream.get(client.streamId) || 0) / 1000) : 0;
- const streamSettings = this._getChannelChatSettings(client.streamId);
- this.sendTo(ws, {
- type: 'auth',
- authenticated: !!client.user,
- newcomer: this._isNewcomer(client),
- username: displayName,
- core_username: client.user?.username || null,
- role: client.user ? client.user.role : 'anon',
- user_id: client.user?.id || null,
- slowmode_seconds: streamSlowSec,
- allow_auto_delete: !client.streamId || streamSettings.viewer_auto_delete_enabled !== 0,
- allow_self_delete_all: !client.streamId || streamSettings.viewer_delete_all_enabled !== 0,
- gifs_enabled: !client.streamId || streamSettings.gifs_enabled !== 0,
- custom_emotes_enabled: !client.streamId || streamSettings.custom_emotes_enabled !== 0,
- custom_sounds_enabled: !client.streamId || streamSettings.custom_sounds_enabled !== 0,
- uploads_mods_only: !!(client.streamId && streamSettings.uploads_mods_only),
- max_sound_seconds: client.streamId ? (streamSettings.max_sound_seconds || 10) : 10,
- emote_scale: client.streamId ? (streamSettings.emote_scale || 100) : 100,
- soundboard_enabled: !client.streamId || streamSettings.soundboard_enabled !== 0,
- soundboard_allow_pitch: !client.streamId || streamSettings.soundboard_allow_pitch !== 0,
- soundboard_allow_speed: !client.streamId || streamSettings.soundboard_allow_speed !== 0,
- slur_filter_enabled: !!(client.streamId && streamSettings.slur_filter_enabled),
- slur_filter_use_builtin: streamSettings.slur_filter_use_builtin !== 0,
- slur_filter_disabled_categories: (() => { try { return JSON.parse(streamSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })(),
- slur_filter_terms: this._parseSlurFilterTerms(streamSettings.slur_filter_terms),
- slur_filter_regexes: this._parseRegexLines(streamSettings.slur_filter_regexes),
- slur_filter_nudge_message: String(streamSettings.slur_filter_nudge_message || ''),
- min_auto_delete_minutes: MIN_CHAT_AUTO_DELETE_MINUTES,
- // Language the channel lives in — the client shows "auto-translated for the
- // streamer" when it isn't English (see server/i18n/translate.js).
- channel_language: this._channelLanguage(client.channelUserId),
- });
- break;
- }
- case 'leave_stream':
- client.streamId = null;
- break;
- case 'get-users':
- this.sendTo(ws, { type: 'users-list', users: this.getUserList(client.streamId) });
- break;
- default:
- break;
- }
- }
-
- /** The stream whose bans and chat settings govern an offline channel-room chatter (cached 30s). */
- _moderationStreamFor(client) {
- if (!client || !client.channelUserId) return null;
- const now = Date.now();
- if (client._modStream && now - client._modStream.at < 30000) return client._modStream.id;
- let id = null;
- try { id = (db.get('SELECT id FROM streams WHERE user_id = ? ORDER BY id DESC LIMIT 1', [client.channelUserId]) || {}).id || null; } catch { id = null; }
- client._modStream = { id, at: now };
- return id;
- }
-
- handleSelfDeleteHistory(ws, client) {
- if (!client) return;
-
- const canBypass = client.streamId ? permissions.canModerateStreamSync(client.user, client.streamId) : false;
- const chatSettings = this._getChannelChatSettings(client.streamId);
- if (client.streamId && !canBypass && chatSettings.viewer_delete_all_enabled === 0) {
- this.sendTo(ws, { type: 'error', message: 'This streamer has disabled viewer self-delete for this chat.' });
- return;
- }
-
- let ids = [];
- if (client.user?.id) {
- ids = db.deleteUserChatMessages(client.user.id, {
- streamId: client.streamId || null,
- deletedBy: client.user.id,
- });
- } else if (client.anonId) {
- ids = db.deleteAnonChatMessages(client.anonId, {
- streamId: client.streamId || null,
- deletedBy: null,
- });
- } else {
- this.sendTo(ws, { type: 'error', message: 'Join chat first before deleting history.' });
- return;
- }
-
- this._broadcastDeletedMessages(client.streamId || null, ids);
-
- try {
- db.logModerationAction({
- scope_type: client.streamId ? 'stream' : 'site',
- scope_id: client.streamId || undefined,
- actor_user_id: client.user?.id || undefined,
- action_type: 'self_message_delete_all',
- details: {
- count: ids.length,
- stream_id: client.streamId || null,
- anon_id: client.user ? null : client.anonId,
- },
- });
- } catch { /* non-critical */ }
-
- this.sendTo(ws, {
- type: 'self-delete-result',
- count: ids.length,
- scope: client.streamId ? 'stream' : 'global',
- });
- }
-
- /**
- * Build a deduplicated list of users in a given stream (or global if null).
- * Returns { logged: [{username, display_name, avatar_url, role}], anonCount: N }
- */
- getUserList(streamId) {
- const seen = new Set();
- const logged = [];
- let anonCount = 0;
- for (const [, c] of this.clients) {
- if (c.streamId !== streamId) continue;
- if (c.user) {
- if (seen.has(c.user.id)) continue;
- seen.add(c.user.id);
- logged.push({
- username: c.user.username,
- display_name: c.user.display_name || c.user.username,
- avatar_url: c.user.avatar_url || null,
- role: c.user.role,
- });
- } else if (c.anonId) {
- if (seen.has(c.anonId)) continue;
- seen.add(c.anonId);
- anonCount++;
- }
- }
- // Sort: admins first, then mods, then alphabetical
- const rolePriority = { admin: 0, global_mod: 1, streamer: 2, user: 3 };
- logged.sort((a, b) => (rolePriority[a.role] ?? 9) - (rolePriority[b.role] ?? 9) || a.display_name.localeCompare(b.display_name));
- return { logged, anonCount };
- }
-
- _parseSlurFilterTerms(rawTerms) {
- return String(rawTerms || '')
- .split(/[\n,]/)
- .map((t) => t.trim().toLowerCase())
- .filter(Boolean)
- .slice(0, 200);
- }
-
- _parseRegexLines(rawRegexes) {
- return String(rawRegexes || '')
- .split(/\r?\n/)
- .map((line) => line.trim())
- .filter(Boolean)
- .slice(0, 300);
- }
-
- // Normalization and matching are delegated to moderation-utils.js (shared module).
- // These thin wrappers preserve the existing call sites inside this class.
- _normalizeSlurText(input) { return _modNormalizeSlurText(input); }
- _normalizeSlurPatternText(input) { return _modNormalizeSlurPatternText(input); }
- _containsCoreSlur(text, disabledCategories = []) { return _modContainsCoreSlur(text, disabledCategories); }
- _containsRegexSlur(text, regexLines) { return _modContainsRegexSlur(text, regexLines); }
- _containsConfiguredSlur(text, terms) { return _modContainsConfiguredSlur(text, terms); }
- _compileRegexList(patternStrings, opts) { return _modCompileRegexList(patternStrings, opts); }
-
- /**
- * Handle a chat message
- */
- handleChatMessage(ws, client, msg) {
- let text = (msg.message || '').trim();
- // Absolute upper bound (DoS guard) = the highest value any channel/admin can configure
- // (admins can set a channel up to 6000). The REAL per-channel limit is enforced below at
- // the `max_message_length` check — this must never be lower than that or it silently
- // swallows long messages the channel actually allows.
- if (!text || text.length > 6000) return;
-
- // ── Ban check ────────────────────────────────────────
- // This has to come before the command dispatch below, not after it. Both command paths
- // return early, so a banned viewer could still run every "!" utility command — queueing
- // and skipping media on the stream they were banned from — and every "/" command,
- // including /me, which puts their text back in the chat they are banned from. A ban means
- // no input of any kind.
- // Offline channel chat has no stream of its own; bans and chat rules come from the channel's
- // most recent stream, so a ban does not stop at the end of a broadcast.
- const modStreamId = client.streamId || this._moderationStreamFor(client);
- if (client.user && db.isUserBanned(client.user.id, modStreamId)) {
- this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
- return;
- }
- if (!this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, modStreamId)) {
- this.sendTo(ws, { type: 'system', message: 'You are banned from this chat.' });
- return;
- }
-
- // ── Stream utility commands (!sr, !queue, !nowplaying, !skip) ──
- if (text.startsWith('!')) {
- this.handleBangCommand(ws, client, text);
- return;
- }
-
- // ── Chat commands ────────────────────────────────────
- if (text.startsWith('/')) {
- this.handleCommand(ws, client, text);
- return;
- }
-
- // ── IP Approval Mode (Anti-VPN) ─────────────────────
- if (client.streamId && client.ip) {
- try {
- const stream = db.getStreamById(client.streamId);
- const channel = stream?.channel_id ? db.getChannelById(stream.channel_id) : (stream ? db.getChannelByUserId(stream.user_id) : null);
- if (channel) {
- const settings = moderation.settingsSync(channel.id);
- if (settings?.ip_approval_mode) {
- const isStaffBypass = client.user && permissions.isGlobalModOrAbove(client.user);
- const isOwner = client.user && stream && stream.user_id === client.user.id;
- if (!isStaffBypass && !isOwner) {
- if (!db.isIpApproved(channel.id, client.ip)) {
- // Auto-approve IPs that have existing non-deleted chat messages in this channel's streams
- const existing = db.get(
- `SELECT 1 FROM chat_messages cm
- JOIN streams s ON cm.stream_id = s.id
- WHERE s.channel_id = ? AND cm.is_deleted = 0
- AND (cm.user_id = ? OR cm.anon_id = ?)
- LIMIT 1`,
- [channel.id, client.user?.id || -1, client.anonId || '']
- );
- if (existing) {
- // This user has chatted before — auto-approve their IP
- db.approveIp(channel.id, client.ip, null, 'auto_existing');
- } else {
- // Hold the message for streamer approval
- const username = client.user ? client.user.display_name : client.anonId;
- db.holdMessageForApproval({
- channelId: channel.id,
- streamId: client.streamId,
- ip: client.ip,
- userId: client.user?.id || null,
- anonId: client.anonId || null,
- username,
- message: text,
- });
- this.sendTo(ws, {
- type: 'system',
- message: 'This channel has IP approval mode enabled. Your message is being held for review by the streamer.',
- });
- // Notify the streamer that a new IP needs approval
- this._notifyStreamerPendingIp(client.streamId, stream.user_id, username, client.ip);
- return;
- }
- }
- }
- }
- }
- } catch { /* non-critical — don't block chat for IP approval errors */ }
- }
-
- // ── Channel moderation settings ──────────────────────
- if (modStreamId) {
- const chatSettings = this._getChannelChatSettings(modStreamId);
- const isStaff = client.user && permissions.isGlobalModOrAbove(client.user);
- const canModerateThisStream = permissions.canModerateStreamSync(client.user, modStreamId);
-
- // Max message length
- const maxLen = Math.max(50, Number(chatSettings.max_message_length || 500));
- if (text.length > maxLen) {
- this.sendTo(ws, { type: 'system', message: `Message too long. Max ${maxLen} characters.` });
- return;
- }
-
- // Anonymous not allowed
- if (!client.user && !chatSettings.allow_anonymous) {
- this.sendTo(ws, { type: 'system', message: 'This channel requires a logged-in account to chat.' });
- return;
- }
-
- // Links disabled — exempt [gif:url] tags (validated separately)
- if (chatSettings.links_allowed === 0 && !isStaff) {
- const textWithoutGifs = text.replace(/\[gif:https?:\/\/[^\]]+\]/gi, '');
- if (/(https?:\/\/|www\.)/i.test(textWithoutGifs)) {
- this.sendTo(ws, { type: 'system', message: 'Links are disabled in this channel chat.' });
- return;
- }
- }
-
- // Validate [gif:url] — only allow trusted domains
- const gifTagMatch = text.match(/\[gif:(https?:\/\/[^\]]+)\]/i);
- if (gifTagMatch) {
- const ALLOWED_GIF_DOMAINS = ['tenor.com', 'media.tenor.com', 'media1.tenor.com', 'c.tenor.com', 'giphy.com', 'media.giphy.com', 'media0.giphy.com', 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com', 'i.giphy.com'];
- try {
- const gifUrl = new URL(gifTagMatch[1]);
- if (!ALLOWED_GIF_DOMAINS.includes(gifUrl.hostname)) {
- this.sendTo(ws, { type: 'system', message: 'Only Tenor and Giphy GIFs are allowed.' });
- return;
- }
- } catch {
- this.sendTo(ws, { type: 'system', message: 'Invalid GIF URL.' });
- return;
- }
- }
-
- // Followers only
- if (chatSettings.followers_only && client.user && !isStaff) {
- const stream = db.getStreamById(modStreamId);
- if (stream && stream.user_id !== client.user.id && !db.isFollowing(client.user.id, stream.user_id)) {
- this.sendTo(ws, { type: 'system', message: 'This chat is currently followers-only.' });
- return;
- }
- }
-
- // Account age gate
- if (chatSettings.account_age_gate_hours && client.user && !isStaff) {
- const ageMs = Date.now() - new Date(client.user.created_at).getTime();
- if (ageMs < Number(chatSettings.account_age_gate_hours) * 3600000) {
- this.sendTo(ws, { type: 'system', message: `This chat requires accounts older than ${chatSettings.account_age_gate_hours} hour(s).` });
- return;
- }
- }
-
- // Optional per-streamer anti-slur filter
- if (chatSettings.slur_filter_enabled && !isStaff && !canModerateThisStream) {
- const blockedTerms = this._parseSlurFilterTerms(chatSettings.slur_filter_terms);
- const configuredRegexLines = this._parseRegexLines(chatSettings.slur_filter_regexes);
- const hitConfigured = blockedTerms.length && this._containsConfiguredSlur(text, blockedTerms);
- const hitCore = chatSettings.slur_filter_use_builtin !== 0 && this._containsCoreSlur(text, (() => { try { return JSON.parse(chatSettings.slur_filter_disabled_categories || '[]') || []; } catch { return []; } })());
- const hitRegex = configuredRegexLines.length && this._containsRegexSlur(text, configuredRegexLines);
- if (hitConfigured || hitCore || hitRegex) {
- this.sendTo(ws, {
- type: 'slur-blocked',
- message: String(chatSettings.slur_filter_nudge_message || '').trim() || DEFAULT_SLUR_NUDGE,
- streamer_enabled: true,
- });
- return;
- }
- }
-
- }
-
- const username = client.user ? client.user.display_name : client.anonId;
- const coreUsername = client.user ? client.user.username : null;
- const role = client.user ? client.user.role : 'anon';
-
- // Voice channel tagging — clients can tag messages with the voice channel they're in
- const voiceChannelId = (typeof msg.voiceChannelId === 'string' && msg.voiceChannelId) ? msg.voiceChannelId : null;
-
- // Reply-to support — client sends reply_to_id, we look up the parent message
- const replyToId = msg.reply_to_id ? parseInt(msg.reply_to_id) : null;
- let replyTo = null;
- if (replyToId) {
- try {
- const parent = db.getChatMessageById(replyToId);
- const parentStillVisible = parent && !parent.is_deleted
- && (!parent.auto_delete_at || new Date(parent.auto_delete_at).getTime() > Date.now());
- if (parentStillVisible) {
- replyTo = {
- id: parent.id,
- username: parent.username,
- user_id: parent.user_id,
- message: parent.message.length > 100 ? parent.message.slice(0, 100) + '…' : parent.message,
- };
- }
- } catch { /* non-critical */ }
- }
-
- const requestedAutoDeleteMinutes = parseInt(msg.auto_delete_minutes, 10);
- const allowViewerAutoDelete = !client.streamId
- || this._getChannelChatSettings(client.streamId).viewer_auto_delete_enabled !== 0
- || permissions.canModerateStreamSync(client.user, client.streamId);
- const autoDeleteAt = Number.isFinite(requestedAutoDeleteMinutes)
- && requestedAutoDeleteMinutes >= MIN_CHAT_AUTO_DELETE_MINUTES
- && allowViewerAutoDelete
- ? new Date(Date.now() + Math.min(MAX_CHAT_AUTO_DELETE_MINUTES, requestedAutoDeleteMinutes) * 60 * 1000).toISOString()
- : null;
-
- const chatMsg = {
- type: 'chat',
- username,
- core_username: coreUsername,
- user_id: client.user?.id || null,
- anon_id: client.anonId,
- role,
- message: text,
- stream_id: client.streamId,
- channel_user_id: client.channelUserId || null,
- is_global: !client.streamId && !client.channelUserId,
- avatar_url: client.user?.avatar_url || null,
- profile_color: client.user?.profile_color || '#999',
- filtered: false,
- timestamp: new Date().toISOString(),
- auto_delete_at: autoDeleteAt,
- };
-
- // Preserve voice channel tag so clients can filter voice-call messages
- if (voiceChannelId) chatMsg.voiceChannelId = voiceChannelId;
-
- // Attach cosmetic data for chat rendering
- if (client.user?.id) {
- try {
- const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);
- if (cosmeticProfile.nameFX) chatMsg.nameFX = cosmeticProfile.nameFX;
- if (cosmeticProfile.particleFX) chatMsg.particleFX = cosmeticProfile.particleFX;
- if (cosmeticProfile.hatFX) chatMsg.hatFX = cosmeticProfile.hatFX;
- if (cosmeticProfile.voiceFX) chatMsg.voiceFX = cosmeticProfile.voiceFX;
- } catch { /* non-critical */ }
-
- // Attach equipped tag for chat rendering
- try {
- const tags = require('./tags');
- const tagProfile = tags.getTagProfile(client.user.id);
- if (tagProfile) chatMsg.tag = tagProfile;
- } catch { /* non-critical */ }
- }
-
- // Save to database
- try {
- const result = db.saveChatMessage({
- stream_id: client.streamId || null,
- channel_user_id: client.channelUserId || null,
- user_id: client.user?.id,
- anon_id: client.anonId,
- username,
- message: text,
- message_type: 'chat',
- is_global: !client.streamId && !client.channelUserId,
- reply_to_id: replyToId,
- auto_delete_at: autoDeleteAt,
- });
- if (result.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
- } catch { /* non-critical */ }
-
- // Attach reply context to broadcast
- if (replyTo) chatMsg.reply_to = replyTo;
-
- // Award OpenCoins for chatting (logged-in users only)
- if (client.user?.id && client.streamId) {
- try {
- const openvibeCoins = require('../monetization/opencoins');
- const coinResult = openvibeCoins.awardChat(client.user.id, client.streamId);
- if (coinResult) {
- this.sendTo(ws, {
- type: 'coin_earned',
- coins: coinResult.coins,
- total: coinResult.total,
- streamerId: coinResult.streamerId,
- reason: 'Chat bonus',
- });
- }
- } catch { /* non-critical */ }
- }
-
- // Welcome first-time chatters in this streamer's channel
- if (client.streamId) {
- try {
- const stream = db.getStreamById(client.streamId);
- if (stream?.user_id) {
- const chatterKey = client.user ? `user:${client.user.id}` : `anon:${client.anonId}`;
- if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
- db.recordFirstChat(chatterKey, stream.user_id);
- const welcomeName = client.user?.display_name || client.user?.username || client.anonId || 'stranger';
- this.broadcastToStream(client.streamId, {
- type: 'system',
- message: `Welcome ${welcomeName} to the chat! 👋`,
- timestamp: new Date().toISOString(),
- });
- }
- }
- } catch { /* non-critical */ }
- }
-
- // Broadcast to appropriate audience
- if (client.streamId || client.channelUserId) {
- // Deliver to the streamer's whole channel room (every live slot + offline
- // viewers) plus the specific stream room. This subsumes cross-slot
- // forwarding so a viewer isn't interrupted when the streamer switches
- // slots or briefly drops offline.
- this.broadcastToChannelRoom(client.channelUserId, client.streamId, chatMsg);
- // Surface on the homepage global feed (tagged with the channel).
- if (client.streamId) this.forwardToGlobal(client.streamId, chatMsg);
- else this.forwardToGlobalByChannel(client.channelUserId, chatMsg);
- // Auto-translate (async): foreign → English for everyone, English → the channel's
- // language for a non-English streamer. Lands as a follow-up 'chat_translation'.
- this._maybeTranslate(chatMsg, client.channelUserId, client.streamId);
- }
- if (client.streamId) {
-
- // Trigger server-side TTS synthesis (async, non-blocking).
- // identityKey uses the immutable login handle (or anon id) so the
- // per-user voice is stable even if the display name changes.
- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)
- this.synthesizeAndBroadcastTTS(
- client.streamId,
- username,
- text,
- chatMsg.voiceFX,
- null,
- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
- null,
- chatMsg.id ? `m${chatMsg.id}` : null
- );
-
- // Check for 101soundboards links in the message (async, non-blocking)
- // floating-ok: processSoundboard catches and logs its own errors (never rejects)
- this.processSoundboard(ws, client, text);
-
- // Let AI chat viewers react to REAL typed chat (streamer or viewers).
- // Bots inject via broadcastToStream directly, so this path only sees
- // genuine human messages.
- try {
- let isMod = false;
- try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }
- require('../integrations/ai-chatbot-service').onRealChatMessage(client.streamId, {
- username,
- message: text,
- userId: client.user?.id || null,
- anonId: client.user ? null : (client.anonId || null),
- isStreamer: !!(client.user && client.user.id === client.channelUserId),
- isMod,
- msgId: chatMsg && chatMsg.id ? chatMsg.id : null,
- channelUserId: client.channelUserId || null,
- });
- } catch { /* non-critical */ }
-
- } else if (!client.channelUserId) {
- // Pure global chat (homepage) — offline channel messages were already
- // delivered to the channel room above.
- this.broadcastGlobal(chatMsg);
- } else if (this._channelOwnerInRoom(client.channelUserId)) {
- // OFFLINE channel chat with the channel OWNER present in their own room:
- // synthesize TTS so their "TTS on my channel even when offline" option has
- // audio to play (client-side settings + the one-speaking-tab lock decide
- // whether it's actually audible). Owner absent = skip the synth cost.
- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)
- this.synthesizeAndBroadcastTTS(
- null,
- username,
- text,
- chatMsg.voiceFX,
- null,
- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`,
- client.channelUserId,
- chatMsg.id ? `m${chatMsg.id}` : null
- );
- }
-
- // Merge real chat into the streamer's PowerChat unified overlay (chat:write).
- // Scoped to the CHANNEL, not the live session. This used to sit inside the
- // `if (client.streamId)` block above, so it only fired while the streamer was
- // live — yet viewers can chat in a channel whenever they like, and those
- // messages are already persisted and broadcast channel-wide. The overlay simply
- // never saw them. Chat is a property of the channel; only TTS, soundboards and
- // AI viewers genuinely need a live stream.
- try {
- const pc = require('../integrations/powerchat-platform');
- // Relay is per CHANNEL, not per slot: a popout pinned to an expired stream
- // id keeps chatting in the same channel, so the slot check follows the
- // channel's current live stream rather than the stale id.
- if (client.channelUserId && pc.channelRelayEnabled(client.channelUserId, client.streamId)) {
- let isMod = false, isSub = false;
- try { isMod = !!(client.user && permissions.canModerateChannelSync(client.user, client.channelUserId)); } catch { /* */ }
- try { isSub = !!(client.user && db.isActiveSubscriber(client.user.id, client.channelUserId)); } catch { /* */ }
- pc.forwardChat(client.channelUserId, {
- chatterName: username,
- externalChatterId: client.user?.id ? ('u' + client.user.id) : ('a' + (client.anonId || 'anon')),
- message: text,
- avatarUrl: client.user?.avatar_url || undefined,
- isModerator: isMod,
- isSubscriber: isSub,
- });
- }
- } catch { /* non-critical */ }
- }
-
- handleBangCommand(ws, client, text) {
- const parts = text.trim().split(/\s+/);
- const cmd = parts[0].toLowerCase();
-
- // Arena: !hype / !beef / !arena (server/arena/arena-chat.js) — chat can only hype; the Arena is pure mic
- try { if (require('../arena/arena-chat').handle(this, ws, client, cmd, parts)) return; } catch (e) { console.warn('[Arena] chat command:', e.message); }
-
- if (cmd === '!gotti') {
- const username = client.user ? client.user.display_name : client.anonId;
- const coreUsername = client.user ? client.user.username : null;
- const role = client.user ? client.user.role : 'anon';
- const gottiMsg = {
- type: 'gotti',
- username,
- core_username: coreUsername,
- user_id: client.user?.id || null,
- anon_id: client.anonId,
- role,
- stream_id: client.streamId,
- is_global: !client.streamId,
- avatar_url: client.user?.avatar_url || null,
- profile_color: client.user?.profile_color || '#999',
- message: GOTTI_CAPTION,
- gif_url: GOTTI_GIF_URL,
- source_url: 'https://tenor.com/view/deez-something-came-in-the-mail-today-deez-nuts-discord-gif-20388619',
- timestamp: new Date().toISOString(),
- };
-
- if (client.user?.id) {
- try {
- const cosmeticProfile = cosmetics.getCosmeticProfile(client.user.id);
- if (cosmeticProfile.nameFX) gottiMsg.nameFX = cosmeticProfile.nameFX;
- if (cosmeticProfile.particleFX) gottiMsg.particleFX = cosmeticProfile.particleFX;
- if (cosmeticProfile.hatFX) gottiMsg.hatFX = cosmeticProfile.hatFX;
- } catch { /* non-critical */ }
-
- try {
- const tags = require('./tags');
- const tagProfile = tags.getTagProfile(client.user.id);
- if (tagProfile) gottiMsg.tag = tagProfile;
- } catch { /* non-critical */ }
- }
-
- if (client.streamId) {
- this.broadcastToStream(client.streamId, gottiMsg);
- this.forwardToGlobal(client.streamId, gottiMsg);
- } else {
- this.broadcastGlobal(gottiMsg);
- }
- return;
- }
-
- if (cmd === '!sb') {
- if (!client.streamId) {
- this.sendTo(ws, { type: 'system', message: 'Soundboard commands only work in a stream chat.' });
- return;
- }
- const chatSettings = this._getChannelChatSettings(client.streamId);
- if (chatSettings.soundboard_enabled === 0) {
- this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
- return;
- }
- const sbArgs = text.slice(parts[0].length).trim();
- const parsed = soundboard.parseSoundboardMessage(`!sb ${sbArgs}`, {
- allowPitch: chatSettings.soundboard_allow_pitch !== 0,
- allowSpeed: chatSettings.soundboard_allow_speed !== 0,
- });
- if (!parsed) {
- this.sendTo(ws, { type: 'system', message: 'Usage: !sb <sound-id or 101soundboards URL> [100p|-100p] [0.5-3 speed]' });
- return;
- }
- // floating-ok: processSoundboard catches and logs its own errors (never rejects)
- this.processSoundboard(ws, client, text);
- return;
- }
-
- if (!client.streamId) {
- this.sendTo(ws, { type: 'system', message: 'Media request commands only work in a stream chat.' });
- return;
- }
-
- const args = text.slice(parts[0].length).trim();
-
- try {
- const stream = db.getStreamById(client.streamId);
- if (!stream?.user_id) {
- this.sendTo(ws, { type: 'system', message: 'Could not resolve the current stream owner.' });
- return;
- }
-
- const mediaQueue = require('../media/media-queue');
-
- switch (cmd) {
- case '!sr':
- case '!yt':
- case '!youtube':
- case '!req':
- case '!request': {
- if (!client.user?.id) {
- this.sendTo(ws, { type: 'system', message: 'You must be logged in to request media.' });
- return;
- }
-
- mediaQueue.addRequest({
- streamerId: stream.user_id,
- streamId: client.streamId,
- userId: client.user.id,
- username: client.user.display_name || client.user.username,
- input: args,
- }).then((request) => {
- this.broadcastToStream(client.streamId, {
- type: 'system',
- message: `${request.username} added “${request.title}”${request.duration_seconds ? ` (${Math.floor(request.duration_seconds / 60)}m${request.duration_seconds % 60}s)` : ''} to the media queue for ${request.cost} gold.`,
- timestamp: new Date().toISOString(),
- });
- this.sendTo(ws, {
- type: 'coin_earned',
- coins: 0,
- currency: 'gold',
- total: null, // client refreshes via /api/coins/balance (network wallet)
- reason: 'Media request purchase',
- });
- }).catch((err) => {
- this.sendTo(ws, { type: 'system', message: err.message || 'Failed to add media request.' });
- });
- return;
- }
-
- case '!queue': {
- const state = mediaQueue.getState(stream.user_id);
- const items = state.queue.slice(0, 3);
- if (!items.length) {
- this.sendTo(ws, { type: 'system', message: 'The media queue is empty. Use !sr, !yt, !youtube, !req, or !request with a URL to queue something.' });
- return;
- }
- const summary = items.map((item, index) => `#${index + 1} ${item.title}`).join(' • ');
- this.sendTo(ws, { type: 'system', message: `Queued: ${summary}` });
- return;
- }
-
- case '!np':
- case '!nowplaying':
- case '!watching': {
- const state = mediaQueue.getState(stream.user_id);
- if (state.now_playing) {
- const np = state.now_playing;
- const durText = np.duration_seconds ? ` [${Math.floor(np.duration_seconds / 60)}m${np.duration_seconds % 60}s]` : '';
- this.sendTo(ws, { type: 'system', message: `Now playing: ${np.title}${durText} (requested by ${np.username})` });
- } else if (state.queue[0]) {
- this.sendTo(ws, { type: 'system', message: `Nothing is playing yet. Up next: ${state.queue[0].title}` });
- } else {
- this.sendTo(ws, { type: 'system', message: 'Nothing is playing right now.' });
- }
- return;
- }
-
- case '!skip': {
- if (!this.canModerate(client) && client.user?.id !== stream.user_id) {
- this.sendTo(ws, { type: 'system', message: 'Only the streamer or a moderator can skip media.' });
- return;
- }
- const ended = mediaQueue.finishCurrent(stream.user_id, 'skipped');
- const next = mediaQueue.startNext(stream.user_id);
- if (ended) {
- this.broadcastToStream(client.streamId, {
- type: 'system',
- message: `Skipped: ${ended.title}${next ? ` • Up next: ${next.title}` : ''}`,
- timestamp: new Date().toISOString(),
- });
- } else {
- this.sendTo(ws, { type: 'system', message: 'Nothing is currently playing.' });
- }
- return;
- }
-
- case '!mediahelp': {
- this.sendTo(ws, { type: 'system', message: 'Media commands: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying, !skip' });
- return;
- }
-
- // ── Cozmo robot commands ───────────────
- case '!forward':
- case '!backward':
- case '!left':
- case '!right':
- case '!liftup':
- case '!liftdown':
- case '!headup':
- case '!headdown': {
- const controlServer = require('../controls/control-server');
- const cozmoMap = {
- '!forward': 'forward', '!backward': 'backward',
- '!left': 'turn_left', '!right': 'turn_right',
- '!liftup': 'lift_up', '!liftdown': 'lift_down',
- '!headup': 'head_up', '!headdown': 'head_down',
- };
- const user = db.getUserById(stream.user_id);
- if (!user) return;
- const hwWs = controlServer.hardwareClients.get(user.stream_key);
- if (!hwWs || hwWs.readyState !== 1) {
- this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });
- return;
- }
- hwWs.send(JSON.stringify({
- type: 'command',
- command: cozmoMap[cmd],
- from_user: client.user?.display_name || `anon${client.anonId || ''}`,
- timestamp: new Date().toISOString(),
- }));
- return;
- }
-
- case '!say': {
- if (!args) return;
- const controlServer2 = require('../controls/control-server');
- const user2 = db.getUserById(stream.user_id);
- if (!user2) return;
- const hwWs2 = controlServer2.hardwareClients.get(user2.stream_key);
- if (!hwWs2 || hwWs2.readyState !== 1) {
- this.sendTo(ws, { type: 'system', message: 'No hardware client connected.' });
- return;
- }
- hwWs2.send(JSON.stringify({
- type: 'command',
- command: `say:${args.slice(0, 200)}`,
- from_user: client.user?.display_name || `anon${client.anonId || ''}`,
- timestamp: new Date().toISOString(),
- }));
- return;
- }
-
- default:
- // Unknown !command → try a per-channel viewer-uploaded sound clip.
- // Forward trailing tokens (e.g. "500p", "0.5") as pitch/speed args.
- this.triggerChannelSound(ws, client, stream, cmd.slice(1), parts.slice(1));
- return;
- }
- } catch (err) {
- this.sendTo(ws, { type: 'system', message: err.message || 'Media command failed.' });
- }
- }
-
- /**
- * Play a per-channel viewer-uploaded sound clip (triggered by !command).
- * Silent no-op when the command is not a registered sound so unknown
- * commands don't spam the chat.
- */
- triggerChannelSound(ws, client, stream, command, args = [], relay = null) {
- try {
- if (!stream?.user_id || !command) return;
- const cmd = String(command).toLowerCase();
- const sound = db.getChannelSoundByCommand(stream.user_id, cmd);
- if (!sound) return; // not a sound command — stay silent
-
- const chatSettings = this._getChannelChatSettings(client.streamId);
- if (chatSettings.custom_sounds_enabled === 0) {
- if (ws) this.sendTo(ws, { type: 'system', message: 'This streamer has disabled chat sound commands.' });
- return;
- }
-
- // Pitch/speed args (e.g. "!honk 500p 0.5"), clamped to the channel's limits.
- const mods = soundboard.parseModifiers(args || [], {
- allowPitch: chatSettings.soundboard_allow_pitch !== 0,
- allowSpeed: chatSettings.soundboard_allow_speed !== 0,
- minSpeed: chatSettings.sound_min_speed,
- maxSpeed: chatSettings.sound_max_speed,
- minPitch: chatSettings.sound_min_pitch_rate,
- maxPitch: chatSettings.sound_max_pitch_rate,
- });
-
- // Banned users can't trigger sounds
- if (client.user && db.isUserBanned(client.user.id, client.streamId)) return;
- if (client.ip && !this._isBanExemptAdmin(client) && db.isIpBanned(client.ip, client.streamId)) return;
-
- // Rate limits — reuse the soundboard limiter (per-user + per-stream window)
- const now = Date.now();
- const rateKey = `${client.streamId}:${relay ? `rs${relay.username}` : (client.user ? `u${client.user.id}` : `a${client.anonId}`)}`;
- const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;
- if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {
- const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);
- if (ws) this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before triggering another sound.` });
- return;
- }
- const streamWindow = this.soundboardStreamLimits.get(client.streamId) || { count: 0, windowStart: now };
- if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {
- streamWindow.count = 0;
- streamWindow.windowStart = now;
- }
- if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {
- if (ws) this.sendTo(ws, { type: 'system', message: 'Too many sounds are playing right now — try again shortly.' });
- return;
- }
-
- // Read + encode the clip
- if (!sound.url || !fs.existsSync(sound.url)) return;
- let audioB64;
- try {
- audioB64 = fs.readFileSync(sound.url).toString('base64');
- } catch { return; }
-
- this.soundboardRateLimits.set(rateKey, now);
- streamWindow.count += 1;
- this.soundboardStreamLimits.set(client.streamId, streamWindow);
-
- const username = relay ? relay.username
- : (client.user ? (client.user.display_name || client.user.username) : (client.anonId || 'someone'));
-
- // Announce as a RICH chat message (same identity/cosmetics/tag as a normal
- // message) so it shows the user's nametag, badges, avatar and cosmetics —
- // not a plain "x played !command" text line.
- const soundMsg = {
- type: 'chat',
- username,
- core_username: relay ? null : (client.user ? client.user.username : null),
- user_id: client.user?.id || null,
- anon_id: relay ? null : client.anonId,
- role: relay ? (relay.role || 'external') : (client.user ? client.user.role : 'anon'),
- message: `played !${cmd}`,
- message_type: 'channel-sound',
- sound: { command: cmd, pitch: mods.pitch, speed: mods.speed, pitchShift: mods.pitchShift, args: (args || []).join(' '), emoteCode: sound.emote_code || '' },
- stream_id: client.streamId,
- is_global: !client.streamId,
- avatar_url: (relay ? relay.avatar_url : client.user?.avatar_url) || null,
- profile_color: (relay ? relay.profile_color : client.user?.profile_color) || '#999',
- source_platform: relay ? relay.sourcePlatform : undefined,
- timestamp: new Date().toISOString(),
- };
- if (!relay && client.user?.id) {
- try {
- const cp = cosmetics.getCosmeticProfile(client.user.id);
- if (cp.nameFX) soundMsg.nameFX = cp.nameFX;
- if (cp.particleFX) soundMsg.particleFX = cp.particleFX;
- if (cp.hatFX) soundMsg.hatFX = cp.hatFX;
- } catch { /* non-critical */ }
- try {
- const tagProfile = require('./tags').getTagProfile(client.user.id);
- if (tagProfile) soundMsg.tag = tagProfile;
- } catch { /* non-critical */ }
- }
- // Persist the announce so it survives a reload — history rebuilds the
- // rich sound row from metadata (the audio itself is never replayed).
- try {
- const saved = db.saveChatMessage({
- stream_id: client.streamId || null,
- channel_user_id: stream.user_id,
- user_id: client.user?.id,
- anon_id: relay ? null : client.anonId,
- username,
- message: soundMsg.message,
- message_type: 'channel-sound',
- is_global: false,
- source_platform: relay ? relay.sourcePlatform : null,
- metadata: { sound: soundMsg.sound },
- });
- if (saved.lastInsertRowid) soundMsg.id = Number(saved.lastInsertRowid);
- } catch { /* non-critical */ }
- this.broadcastToStream(client.streamId, soundMsg);
- // Also surface it on the global chat feed / global overlay (with stream_channel).
- this.forwardToGlobal(client.streamId, soundMsg);
- this.broadcastToStream(client.streamId, {
- type: 'soundboard-audio',
- username,
- title: `!${cmd}`,
- audio: audioB64,
- mimeType: sound.mime || 'audio/mpeg',
- pitch: mods.pitch,
- speed: mods.speed,
- pitchShift: mods.pitchShift,
- source: 'channel-sound',
- timestamp: new Date().toISOString(),
- });
- } catch (err) {
- console.error('[ChannelSound] trigger error:', err.message);
- }
- }
-
- /**
- * Handle chat commands
- */
- handleCommand(ws, client, text) {
- const parts = text.slice(1).split(' ');
- const cmd = parts[0].toLowerCase();
- const args = parts.slice(1).join(' ');
- const argParts = parts.slice(1);
-
- switch (cmd) {
- case 'ai': {
- // /ai pause|resume|mute <bot>|unmute <bot>|status — channel mods + owner only.
- if (!this.canModerate(client)) { this.sendTo(ws, { type: 'system', message: 'Only moderators can control the AI viewers.' }); return; }
- try {
- const engine = require('../integrations/ai-chatbot-service');
- const reply = engine.onModCommand ? engine.onModCommand(client.channelUserId, client.streamId, parts.slice(1), { by: client.user?.username }) : 'AI viewers: command not supported by this engine.';
- this.sendTo(ws, { type: 'system', message: reply || 'ok' });
- } catch (e) { this.sendTo(ws, { type: 'system', message: `AI viewers: ${e.message}` }); }
- return;
- }
- case 'help':
- this.sendTo(ws, {
- type: 'system',
- message: `Commands: /help, /tts <message>, /color <#hex>, /viewers, /uptime, /me <action>, /paste <content>` +
- `\nMedia: !sr/!yt/!youtube/!req/!request <url>, !queue, !nowplaying` +
- (this.canModerate(client)
- ? `\nMod: /ban <user>, /unban <user>, /timeout <user> [seconds], /clear, /slow <seconds>`
- : ''),
- });
- break;
-
- case 'tts':
- if (!args) {
- this.sendTo(ws, { type: 'system', message: 'Usage: /tts <message>' });
- return;
- }
- {
- const ttsMsg = {
- type: 'tts',
- username: client.user?.display_name || client.anonId,
- core_username: client.user?.username || null,
- message: args,
- timestamp: new Date().toISOString(),
- };
- // Attach voice cosmetic if equipped
- let voiceFX = null;
- if (client.user?.id) {
- try {
- const cp = cosmetics.getCosmeticProfile(client.user.id);
- if (cp.voiceFX) {
- ttsMsg.voiceFX = cp.voiceFX;
- voiceFX = cp.voiceFX;
- }
- } catch { /* non-critical */ }
- }
- this.broadcastToStream(client.streamId, ttsMsg);
-
- // Also synthesize server-side TTS for site-wide mode
- // floating-ok: synthesizeAndBroadcastTTS catches and logs its own errors (never rejects)
- this.synthesizeAndBroadcastTTS(
- client.streamId,
- ttsMsg.username,
- args,
- voiceFX,
- null,
- client.user ? `user:${client.user.username}` : `anon:${client.anonId}`
- );
- }
- break;
-
- case 'color':
- if (!client.user) {
- this.sendTo(ws, { type: 'system', message: 'You must be logged in to change color.' });
- } else if (!args || !/^#[0-9a-fA-F]{6}$/.test(args)) {
- this.sendTo(ws, { type: 'system', message: 'Usage: /color #ff00ff (hex color code)' });
- } else {
- db.run('UPDATE users SET profile_color = ? WHERE id = ?', [args, client.user.id]);
- client.user.profile_color = args;
- this.sendTo(ws, { type: 'system', message: `Color set to ${args}` });
- }
- break;
-
- case 'viewers': {
- const count = this.getStreamViewerCount(client.streamId);
- this.sendTo(ws, { type: 'system', message: `${count} viewer(s) in chat` });
- break;
- }
-
- case 'uptime': {
- if (!client.streamId) {
- this.sendTo(ws, { type: 'system', message: 'Not in a stream chat.' });
- break;
- }
- try {
- const stream = db.getStreamById(client.streamId);
- if (stream && stream.started_at) {
- const start = new Date(stream.started_at.replace(' ', 'T') + 'Z').getTime();
- const elapsed = Date.now() - start;
- const hours = Math.floor(elapsed / 3600000);
- const minutes = Math.floor((elapsed % 3600000) / 60000);
- const seconds = Math.floor((elapsed % 60000) / 1000);
- const parts = [];
- if (hours > 0) parts.push(`${hours}h`);
- parts.push(`${minutes}m`);
- parts.push(`${seconds}s`);
- this.sendTo(ws, { type: 'system', message: `Stream uptime: ${parts.join(' ')}` });
- } else {
- this.sendTo(ws, { type: 'system', message: 'Stream is offline.' });
- }
- } catch {
- this.sendTo(ws, { type: 'system', message: 'Could not determine uptime.' });
- }
- break;
- }
-
- case 'w':
- case 'whisper':
- case 'msg': {
- this.sendTo(ws, { type: 'system', message: 'Whispers have been replaced by DMs! Click the message icon in the navbar to open Messenger.' });
- break;
- }
-
- case 'me': {
- if (!args) {
- this.sendTo(ws, { type: 'system', message: 'Usage: /me <action>' });
- break;
- }
- const username = client.user?.display_name || client.anonId;
- this.broadcastToStream(client.streamId, {
- type: 'chat',
- username,
- core_username: client.user?.username || null,
- role: client.user?.role || 'anon',
- message: `* ${username} ${args}`,
- is_action: true,
- timestamp: new Date().toISOString(),
- });
- break;
- }
-
- case 'ban':
- this.handleModAction(ws, client, 'ban', args);
- break;
-
- case 'unban':
- this.handleModAction(ws, client, 'unban', args);
- break;
-
- case 'timeout':
- this.handleModAction(ws, client, 'timeout', args);
- break;
-
- case 'clear':
- if (this.canModerate(client)) {
- this.broadcastToStream(client.streamId, { type: 'clear' });
- this.logChatModeration(client, client.streamId ? 'clear_chat' : 'clear_global_chat');
- } else {
- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
- }
- break;
-
- case 'slow': {
- if (this.canModerate(client)) {
- let seconds;
- if (args === 'off' || args === 'disable' || args === '0') {
- seconds = 0;
- } else {
- seconds = parseInt(args);
- if (!Number.isFinite(seconds) || seconds < 0) seconds = 3;
- }
- // Per-stream slow mode (not global). OpenVibe.Chat owns channel_moderation_settings
- // (roadmap T3) and persists /slow itself; Live's own chat server only holds it in memory.
- if (client.streamId) {
- this.slowModeByStream.set(client.streamId, seconds > 0 ? seconds * 1000 : 0);
- }
- // Dedicated slowmode event so clients can show/hide UI
- this.broadcastToStream(client.streamId, {
- type: 'slowmode',
- seconds,
- });
- const msg = seconds > 0
- ? `Slow mode enabled: ${seconds}s between messages`
- : 'Slow mode disabled.';
- this.broadcastToStream(client.streamId, {
- type: 'system',
- message: msg,
- });
- this.logChatModeration(client, 'slowmode_update', { seconds });
- } else {
- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
- }
- break;
- }
-
- case 'paste': {
- // /paste <content> — create a quick paste from chat
- if (!args.trim()) {
- this.sendTo(ws, { type: 'system', message: 'Usage: /paste <content to share>' });
- break;
- }
- {
- const title = `Chat paste by ${client.username || client.displayName || 'anon'}`;
- const userId = client.userId || client.user?.id || null;
- // Pastes live in OpenVibe.Media now — post through the media client.
- require('../pastes-client').createPaste({
- title,
- content: args.trim(),
- language: 'auto',
- visibility: 'public',
- user_id: userId || undefined,
- stream_id: client.streamId || undefined,
- }).then((paste) => {
- const siteUrl = process.env.SITE_URL || '';
- const pasteUrl = `${siteUrl}/p/${paste.slug}`;
- // Show link to everyone in stream
- this.broadcastToStream(client.streamId, {
- type: 'system',
- message: `📋 ${client.displayName || client.username || 'Anonymous'} shared a paste: ${pasteUrl}`,
- });
- }).catch((err) => {
- console.error('[Chat] /paste error:', err.message);
- this.sendTo(ws, { type: 'system', message: 'Failed to create paste.' });
- });
- }
- break;
- }
-
- default:
- this.sendTo(ws, { type: 'system', message: `Unknown command: /${cmd}. Type /help for a list.` });
- }
- }
-
- /**
- * Handle mod actions (ban, unban, timeout)
- */
- handleModAction(ws, client, action, args) {
- if (!this.canModerate(client)) {
- this.sendTo(ws, { type: 'system', message: 'You do not have permission.' });
- return;
- }
-
- const target = args.split(' ')[0];
- if (!target) return;
- // Where the ban applies. In an offline channel room it is that channel's latest stream: a
- // null stream id is a SITE-WIDE ban row (and /unban with null lifts site-wide rows), which
- // only global chat — where canModerate() already requires global staff — may write.
- const scopeStreamId = client.streamId || (client.channelUserId ? this._moderationStreamFor(client) : null);
- if (!scopeStreamId && client.channelUserId) {
- this.sendTo(ws, { type: 'system', message: 'This channel has no stream to moderate yet.' });
- return;
- }
- const scoped = !!scopeStreamId;
-
- switch (action) {
- case 'ban': {
- const targetUser = db.getUserByUsername(target);
- if (targetUser) {
- // Prevent non-admins from banning admins
- if (permissions.isGlobalModOrAbove(targetUser) && targetUser.role === 'admin' && !permissions.isAdmin(client.user)) {
- this.sendTo(ws, { type: 'system', message: 'You cannot ban an admin.' });
- return;
- }
- db.run(
- `INSERT INTO bans (stream_id, user_id, reason, banned_by) VALUES (?, ?, ?, ?)`,
- [scopeStreamId, targetUser.id, 'Banned by moderator', client.user.id]
- );
- this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
- this.broadcastToStream(client.streamId, {
- type: 'system', message: `${target} has been banned.`
- });
- this.logChatModeration(client, scoped ? 'channel_ban' : 'site_ban', { username: targetUser.username }, targetUser.id);
- } else {
- // Ban by anon ID
- const anonTarget = this.findClientByAnonId(target, client.streamId);
- if (anonTarget) {
- db.run(
- `INSERT INTO bans (stream_id, ip_address, anon_id, reason, banned_by) VALUES (?, ?, ?, ?, ?)`,
- [scopeStreamId, anonTarget.ip, target, 'Banned by moderator', client.user.id]
- );
- this.sendTo(ws, { type: 'system', message: `${target} has been banned.` });
- }
- this.logChatModeration(client, scoped ? 'channel_anon_ban' : 'site_anon_ban', { anon_id: target });
- }
- break;
- }
- case 'timeout': {
- const duration = parseInt(args.split(' ')[1]) || 300; // Default 5 min
- const targetUser = db.getUserByUsername(target);
- const expires = new Date(Date.now() + duration * 1000).toISOString();
- if (targetUser) {
- db.run(
- `INSERT INTO bans (stream_id, user_id, reason, banned_by, expires_at) VALUES (?, ?, ?, ?, ?)`,
- [scopeStreamId, targetUser.id, `Timeout ${duration}s`, client.user.id, expires]
- );
- this.logChatModeration(client, scoped ? 'channel_timeout' : 'site_timeout', { username: targetUser.username, duration }, targetUser.id);
- }
- this.sendTo(ws, { type: 'system', message: `${target} timed out for ${duration}s.` });
- break;
- }
- case 'unban': {
- const targetUser = db.getUserByUsername(target);
- if (targetUser) {
- // A channel unban lifts that channel's rows only; site-wide rows are global chat's.
- if (scoped) db.run('DELETE FROM bans WHERE user_id = ? AND stream_id = ?', [targetUser.id, scopeStreamId]);
- else db.run('DELETE FROM bans WHERE user_id = ? AND stream_id IS NULL', [targetUser.id]);
- this.logChatModeration(client, scoped ? 'channel_unban' : 'site_unban', { username: targetUser.username }, targetUser.id);
- }
- this.sendTo(ws, { type: 'system', message: `${target} has been unbanned.` });
- break;
- }
- }
- }
-
- // ── Helper methods ───────────────────────────────────────
- /**
- * Synthesize TTS audio for a chat message and broadcast to stream.
- * Runs asynchronously — does not block message delivery.
- */
- // Is the channel owner connected to their OWN channel room right now? Gates
- // offline-channel TTS synthesis so we never pay for audio nobody will hear.
- _channelOwnerInRoom(channelUserId) {
- if (!channelUserId) return false;
- for (const [ws, client] of this.clients) {
- if (ws.readyState !== WebSocket.OPEN) continue;
- if (client.user && Number(client.user.id) === Number(channelUserId)
- && Number(client.channelUserId) === Number(channelUserId)) return true;
- }
- return false;
- }
-
- // TTS audio delivery: to the stream room while live, to the CHANNEL room for
- // offline channel chat (streamId null).
- _broadcastTtsPayload(streamId, channelUserId, payload) {
- if (streamId) this.broadcastToStream(streamId, payload);
- else if (channelUserId) this.broadcastToChannelRoom(channelUserId, null, payload);
- }
-
- // streamId may be null for OFFLINE channel chat — pass channelUserId instead and
- // the audio is delivered to the channel room rather than a stream room.
- async synthesizeAndBroadcastTTS(streamId, username, text, voiceFX, sourcePlatform = null, identityKey = null, channelUserId = null, ttsKey = null) {
- // Queue accounting key: per-stream when live, per-channel when offline.
- const queueKey = streamId || (channelUserId ? `ch:${channelUserId}` : null);
- // Duplicate suppression by MESSAGE IDENTITY, not content: the same chat
- // message synthesized twice (duplicated bridge, double-fired relay) is a dupe;
- // a user legitimately typing the same text again is NOT and must be read
- // again. Callers pass ttsKey (the persisted message id). The content fallback
- // for id-less paths uses a 2s window — wide enough for a racing double
- // delivery, far too narrow to eat a real repeat.
- if (!this._recentTtsDedupe) this._recentTtsDedupe = new Map();
- const dedupeKey = ttsKey ? `k:${queueKey}|${ttsKey}` : `${queueKey}|${identityKey || username}|${String(text).slice(0, 200)}`;
- const windowMs = ttsKey ? 30000 : 2000;
- const nowMs = Date.now();
- const lastMs = this._recentTtsDedupe.get(dedupeKey);
- if (lastMs && nowMs - lastMs < windowMs) { console.log(`[TTS] deduped duplicate synth for ${username} in ${queueKey} (${ttsKey ? 'same message id' : 'same content <2s'})`); return; }
- this._recentTtsDedupe.set(dedupeKey, nowMs);
- if (this._recentTtsDedupe.size > 500) {
- for (const [k, t] of this._recentTtsDedupe) if (nowMs - t > 60000) this._recentTtsDedupe.delete(k);
- }
- try {
- // "." prefix = user opted this message out of TTS — never synthesize or broadcast it.
- if (String(text || '').trimStart().startsWith('.')) return;
-
- const settings = ttsEngine.getTTSSettings();
- if (!settings.enabled) return;
-
- // Queue limit checks
- const limits = ttsEngine.getQueueLimits();
- const globalCount = this.ttsQueueSize.get(queueKey) || 0;
- if (globalCount >= limits.maxGlobal) return;
-
- // Determine voice ID from equipped cosmetic
- let voiceId = null;
- if (voiceFX?.itemId && ttsEngine.VOICE_CATALOG[voiceFX.itemId]) {
- voiceId = voiceFX.itemId;
- }
-
- // Increment queue counter
- this.ttsQueueSize.set(queueKey, globalCount + 1);
-
- // Honor the channel's configured TTS length (streamers can raise it up to 1200);
- // falls back to the site default when unset. Without this the server synth always
- // truncated at the global 200 even when the channel allowed more.
- let ttsMaxOverride;
- try { ttsMaxOverride = Number(this._getChannelChatSettings(streamId).tts_max_length) || undefined; } catch { /* use engine default */ }
-
- let result;
- if (!voiceId && settings.perUserVoices) {
- // No equipped cosmetic voice → give this chatter a stable per-username voice.
- const idKey = identityKey || username || 'anon';
- result = await ttsEngine.synthesizeUserVoice(text, idKey, username, ttsMaxOverride);
- } else {
- result = await ttsEngine.synthesize(text, voiceId || settings.defaultVoice, username, ttsMaxOverride);
- }
-
- // Decrement queue counter
- const current = this.ttsQueueSize.get(queueKey) || 1;
- this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
-
- if (!result) return;
-
- // Broadcast TTS audio to all clients in the stream
- this._broadcastTtsPayload(streamId, channelUserId, {
- type: 'tts-audio',
- username,
- // Stable sender identity ("user:<login>" / "anon:<id>") so clients can
- // skip their OWN message's TTS locally — senders were hearing their
- // message twice (their tab + the stream audio).
- sender_key: identityKey || undefined,
- // Unique per utterance — clients dedupe playback on this, so identical
- // TEXT from separate messages still reads every time.
- ttsKey: ttsKey || undefined,
- message: text,
- audio: result.audio,
- mimeType: result.mimeType,
- engine: result.engine,
- voiceName: result.voiceName,
- voiceId: result.voiceId,
- fallback: result.fallback || false,
- source_platform: sourcePlatform || undefined,
- timestamp: new Date().toISOString(),
- });
- } catch (err) {
- // Ensure queue counter is decremented on error
- const current = this.ttsQueueSize.get(queueKey) || 1;
- this.ttsQueueSize.set(queueKey, Math.max(0, current - 1));
- console.error('[TTS] Synthesis broadcast error:', err.message);
- }
- }
-
- /**
- * Process a chat message for 101soundboards links or !sb commands.
- * Fetches the audio, caches it, and broadcasts to stream clients.
- */
- async processSoundboard(ws, client, text) {
- try {
- if (!soundboard.isConfigured()) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: 'Soundboard is not configured on this server.' });
- }
- return;
- }
-
- const streamId = client?.streamId || null;
- if (!streamId) return;
-
- const chatSettings = this._getChannelChatSettings(streamId);
- if (chatSettings.soundboard_enabled === 0) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: 'This streamer has disabled 101soundboards in chat.' });
- }
- return;
- }
-
- const parsed = soundboard.parseSoundboardMessage(text, {
- allowPitch: chatSettings.soundboard_allow_pitch !== 0,
- allowSpeed: chatSettings.soundboard_allow_speed !== 0,
- });
- if (!parsed) return;
-
- const bannedIds = new Set(soundboard.normalizeBannedIds(chatSettings.soundboard_banned_ids));
- if (bannedIds.has(String(parsed.soundId))) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: 'That 101soundboards sound is blocked by this streamer.' });
- }
- return;
- }
-
- const userKey = client.user?.id ? `user:${client.user.id}` : `anon:${client.anonId || client.ip}`;
- const rateKey = `${streamId}:${userKey}`;
- const lastUsedAt = this.soundboardRateLimits.get(rateKey) || 0;
- const now = Date.now();
- if ((now - lastUsedAt) < SOUNDBOARD_RATE_LIMIT_MS) {
- if (String(text || '').trim().startsWith('!sb')) {
- const remaining = Math.ceil((SOUNDBOARD_RATE_LIMIT_MS - (now - lastUsedAt)) / 1000);
- this.sendTo(ws, { type: 'system', message: `Wait ${remaining}s before using another soundboard clip.` });
- }
- return;
- }
-
- // Per-stream global rate limit (15 sounds/min across all users)
- const streamWindow = this.soundboardStreamLimits.get(streamId) || { count: 0, windowStart: now };
- if ((now - streamWindow.windowStart) >= SOUNDBOARD_STREAM_WINDOW_MS) {
- streamWindow.count = 0;
- streamWindow.windowStart = now;
- }
- if (streamWindow.count >= SOUNDBOARD_STREAM_MAX_PER_WINDOW) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: 'Too many soundboard clips are playing in this stream right now. Try again soon.' });
- }
- return;
- }
- streamWindow.count++;
- this.soundboardStreamLimits.set(streamId, streamWindow);
-
- const result = await soundboard.getSoundboardAudio(parsed.soundId);
- if (!result) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: 'Could not load that 101soundboards clip.' });
- }
- return;
- }
-
- this.soundboardRateLimits.set(rateKey, now);
-
- const username = client.user?.display_name || client.user?.username || client.anonId || 'anon';
- const coreUsername = client.user?.username || null;
- const role = client.user ? client.user.role : 'anon';
-
- const sbMsg = {
- type: 'chat',
- username,
- core_username: coreUsername,
- user_id: client.user?.id || null,
- anon_id: client.anonId,
- role,
- stream_id: streamId,
- avatar_url: client.user?.avatar_url || null,
- profile_color: client.user?.profile_color || '#999',
- message_type: 'soundboard',
- message: `played ${result.title}`,
- soundboard: {
- soundId: result.soundId,
- title: result.title,
- sourceUrl: result.sourceUrl,
- pitch: parsed.pitch,
- speed: parsed.speed,
- pitchShift: parsed.pitchShift,
- },
- timestamp: new Date().toISOString(),
- };
- // Persist so the announce survives a reload (rebuilt from metadata).
- try {
- const saved = db.saveChatMessage({
- stream_id: streamId,
- user_id: client.user?.id,
- anon_id: client.anonId,
- username,
- message: sbMsg.message,
- message_type: 'soundboard',
- is_global: false,
- metadata: { soundboard: sbMsg.soundboard },
- });
- if (saved.lastInsertRowid) sbMsg.id = Number(saved.lastInsertRowid);
- } catch { /* non-critical */ }
- this.broadcastToStream(streamId, sbMsg);
-
- this.broadcastToStream(streamId, {
- type: 'soundboard-audio',
- username,
- audio: result.audio,
- mimeType: result.mimeType,
- soundId: result.soundId,
- title: result.title,
- sourceUrl: result.sourceUrl,
- pitch: parsed.pitch,
- speed: parsed.speed,
- pitchShift: parsed.pitchShift,
- timestamp: new Date().toISOString(),
- });
- } catch (err) {
- if (String(text || '').trim().startsWith('!sb')) {
- this.sendTo(ws, { type: 'system', message: err.message || 'Could not load that 101soundboards clip.' });
- }
- console.error('[Soundboard] Process error:', err.message);
- }
- }
-
- /**
- * Can this client moderate their current stream's chat?
- * Uses the permission layer: admin, global_mod, stream owner, or channel mod.
- * Streamers do NOT get mod powers in other people's chats.
- */
- canModerate(client) {
- if (!client.user) return false;
- // Offline channel chat: the channel's latest stream stands in (see _moderationStreamFor).
- const sid = client.streamId || this._moderationStreamFor(client);
- return !!sid && permissions.canModerateStreamSync(client.user, sid) || permissions.isGlobalModOrAbove(client.user);
- }
-
- /** @deprecated Use canModerate(client) — kept temporarily for any external callers */
- isMod(client) {
- return this.canModerate(client);
- }
-
- findClientByAnonId(anonId, streamId) {
- for (const [, info] of this.clients) {
- if (info.anonId === anonId && info.streamId === streamId) {
- return info;
- }
- }
- return null;
- }
-
- /**
- * Find the IP address of a connected user by user ID.
- * Returns the IP from their most recent connection, or null if not connected.
- */
- getConnectedUserIp(userId) {
- for (const [, info] of this.clients) {
- if (info.user?.id === userId) return info.ip;
- }
- return null;
- }
-
- /**
- * Disconnect all WebSocket clients for a given user ID or IP.
- * Used after banning to immediately kick them.
- */
- disconnectUser({ userId, ip, streamId } = {}) {
- for (const [ws, info] of this.clients) {
- const matchUser = userId && info.user?.id === userId;
- const matchIp = ip && info.ip === ip;
- const matchStream = streamId ? info.streamId === streamId : true;
- if ((matchUser || matchIp) && matchStream) {
- this.sendTo(ws, { type: 'system', message: 'You have been banned.' });
- try { ws.close(1000, 'banned'); } catch {}
- }
- }
- }
-
- findWsByUsername(name, streamId) {
- const nameLower = name.toLowerCase();
- for (const [ws, info] of this.clients) {
- if (info.streamId !== streamId) continue;
- const uname = info.user?.display_name || info.user?.username || info.anonId;
- if (uname && uname.toLowerCase() === nameLower) return ws;
- }
- return null;
- }
-
- sendTo(ws, data) {
- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(JSON.stringify(data));
- }
- }
-
- /** Language a channel lives in ('en' when unknown). Cached inside i18n. */
- _channelLanguage(channelUserId) {
- if (!channelUserId) return 'en';
- try { return require('../i18n/translate').channelLanguage(channelUserId); } catch { return 'en'; }
- }
-
- /**
- * Translate a just-broadcast chat line and push the translation to the same rooms as a
- * 'chat_translation' event (clients attach it under the message by id). Persisted into
- * chat_messages.metadata so history shows it too. Fire-and-forget; never throws.
- */
- _maybeTranslate(chatMsg, channelUserId, streamId) {
- if (!chatMsg || !chatMsg.message || chatMsg.message_type && chatMsg.message_type !== 'chat') return;
- let i18n;
- try { i18n = require('../i18n/translate'); } catch { return; }
- if (!i18n.available()) return;
- let chanUid = channelUserId || null;
- if (!chanUid && streamId) { try { chanUid = db.getStreamById(streamId)?.user_id || null; } catch { /* */ } }
- const text = String(chatMsg.message).replace(/^\s*\.\s?/, ''); // ".msg" = tts-off marker
- i18n.translateChatMessage(text, chanUid).then((tr) => {
- if (!tr || !tr.text) return;
- const evt = {
- type: 'chat_translation', id: chatMsg.id || null, from: tr.from, to: tr.to, text: tr.text,
- stream_id: streamId || null, channel_user_id: chanUid, timestamp: new Date().toISOString(),
- };
- this.broadcastToChannelRoom(chanUid, streamId, evt);
- if (streamId) this.forwardToGlobal(streamId, evt);
- else this.forwardToGlobalByChannel(chanUid, evt);
- if (chatMsg.id) { try { db.mergeChatMessageMetadata(chatMsg.id, { translation: tr }); } catch { /* */ } }
- }).catch(() => { /* best-effort */ });
- }
-
- broadcastToStream(streamId, data) {
- const msg = JSON.stringify(data);
- for (const [ws, client] of this.clients) {
- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(msg);
- }
- }
- }
-
- /**
- * Deliver to a streamer's whole channel room: any client in that streamer's
- * channel (`channelUserId`, stable across slots + offline) OR in the specific
- * live-session stream room (`streamId`, for backward-compat with clients that
- * joined before channel rooms existed). Deduped per connection.
- */
- broadcastToChannelRoom(channelUserId, streamId, data) {
- const msg = JSON.stringify(data);
- for (const [ws, client] of this.clients) {
- if (ws.readyState !== WebSocket.OPEN || ws.bufferedAmount > MAX_SEND_BACKPRESSURE) continue;
- const inChannel = channelUserId && client.channelUserId === channelUserId;
- const inStream = streamId && client.streamId === streamId;
- if (inChannel || inStream) ws.send(msg);
- }
- }
-
- /**
- * Forward an OFFLINE channel message to the homepage global feed, tagged with
- * the channel username (parallels forwardToGlobal but keyed by user id since
- * there's no live session id to resolve).
- */
- forwardToGlobalByChannel(channelUserId, data) {
- if (!channelUserId) return;
- let username = null;
- try { username = db.getUserById(channelUserId)?.username; } catch { /* ignore */ }
- if (!username) return;
- const globalMsg = JSON.stringify({ ...data, stream_channel: username, source_channel: username });
- for (const [ws, client] of this.clients) {
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(globalMsg);
- }
- }
- }
-
- broadcastGlobal(data) {
- const msg = JSON.stringify(data);
- for (const [ws, client] of this.clients) {
- // Pure global clients only (see forwardToGlobal) — channel/stream viewers get
- // global activity via their dedicated cross-feed socket, not their main one.
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(msg);
- }
- }
- }
-
- /**
- * Notify everyone watching any of a channel owner's live streams that the
- * channel's emotes/sounds changed, so their chat pickers refresh live
- * (no page reload). No-op when the owner has no live streams / viewers.
- */
- broadcastToOwnerStreams(ownerUserId, data) {
- try {
- const streams = db.getLiveStreamsByUserId(ownerUserId) || [];
- if (!streams.length) return;
- const ids = new Set(streams.map(s => String(s.id)));
- const msg = JSON.stringify(data);
- for (const [ws, client] of this.clients) {
- if (ids.has(String(client.streamId)) && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(msg);
- }
- }
- } catch { /* ignore */ }
- }
-
- /**
- * Forward a stream message to all global-connected clients
- * so the global chat feed shows activity from every stream.
- */
- forwardToGlobal(streamId, data) {
- // Look up stream owner username + slot title/slug (cached per stream)
- if (!this._streamNameCache) this._streamNameCache = new Map();
- let info = this._streamNameCache.get(streamId);
- if (!info || typeof info !== 'object') {
- try {
- const stream = db.getStreamById(streamId);
- info = {
- username: stream?.username || `stream-${streamId}`,
- title: stream?.managed_stream_title || stream?.title || null,
- slug: stream?.managed_stream_slug || null,
- managedId: stream?.managed_stream_id || null,
- };
- this._streamNameCache.set(streamId, info);
- // Auto-expire cache after 5 min
- setTimeout(() => this._streamNameCache.delete(streamId), 300000);
- } catch {
- info = { username: `stream-${streamId}`, title: null, slug: null, managedId: null };
- }
- }
- const globalMsg = JSON.stringify({
- ...data,
- stream_channel: info.username,
- source_channel: info.username,
- source_stream_id: streamId,
- source_stream_title: info.title,
- source_slug: info.slug,
- source_managed_id: info.managedId,
- source_is_live: 1, // this path only fires for a live send
- });
- for (const [ws, client] of this.clients) {
- // Only PURE global clients (no stream AND no channel) — otherwise an
- // offline-channel viewer's main socket (streamId null, channelUserId set)
- // would render this via addChatMessage AND get it again as a cross-feed on
- // its dedicated global socket → duplicate. Channel/stream viewers receive the
- // cross-feed exclusively through their separate global-feed connection.
- if (!client.streamId && !client.channelUserId && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(globalMsg);
- }
- }
- }
-
- /**
- * Deliver a stream message to viewers of the SAME streamer's OTHER live slots
- * ("Show All Chats Under This Streamer"). Tagged with source-slot info so the
- * client can show a stream-title badge + let viewers hop between slots. Only
- * fires when the streamer has 2+ live slots.
- */
- forwardToStreamerRooms(streamId, data) {
- try {
- const stream = db.getStreamById(streamId);
- if (!stream || !stream.user_id) return;
- const siblings = db.getLiveStreamsByUserId(stream.user_id) || [];
- if (siblings.length < 2) return;
- const siblingIds = new Set(siblings.map(s => s.id));
- const payload = JSON.stringify({
- ...data,
- cross_stream: true,
- source_stream_id: streamId,
- source_stream_title: stream.managed_stream_title || stream.title || null,
- source_slug: stream.managed_stream_slug || null,
- source_managed_id: stream.managed_stream_id || null,
- source_channel: stream.username || null,
- source_is_live: 1,
- });
- for (const [ws, client] of this.clients) {
- if (client.streamId && client.streamId !== streamId && siblingIds.has(client.streamId)
- && ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(payload);
- }
- }
- } catch { /* non-critical */ }
- }
-
- _broadcastDeletedMessages(streamId, ids) {
- if (!Array.isArray(ids) || ids.length === 0) return;
- const payload = { type: 'delete-messages', ids };
- if (streamId) {
- this.broadcastToStream(streamId, payload);
- this.forwardToGlobal(streamId, payload);
- return;
- }
- this.broadcastAll(payload);
- }
-
- _sweepExpiredChatMessages() {
- try {
- const expired = db.deleteExpiredChatMessages(500);
- if (!expired.length) return;
- const byScope = new Map();
- for (const row of expired) {
- const key = row.stream_id ? `stream:${row.stream_id}` : 'global';
- if (!byScope.has(key)) byScope.set(key, []);
- byScope.get(key).push(row.id);
- }
- for (const [key, ids] of byScope.entries()) {
- const streamId = key === 'global' ? null : parseInt(key.split(':')[1], 10);
- this._broadcastDeletedMessages(streamId, ids);
- }
- } catch (err) {
- console.warn('[Chat] Failed to sweep expired auto-delete messages:', err.message);
- }
- }
-
- broadcastUserCount(streamId) {
- const count = this.getStreamViewerCount(streamId);
- const data = JSON.stringify({ type: 'user-count', count, stream_id: streamId });
- for (const [ws, client] of this.clients) {
- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
- ws.send(data);
- }
- }
- // Persist to DB so the /api/streams endpoint returns real counts
- if (streamId) {
- try { db.updateViewerCount(streamId, count); } catch {}
- }
- }
-
- /**
- * Record viewer snapshots for all active streams.
- * Called every 60 seconds by the snapshot interval timer.
- */
- _recordViewerSnapshots() {
- // Collect unique active stream IDs
- const streamIds = new Set();
- for (const [, client] of this.clients) {
- if (client.streamId) streamIds.add(client.streamId);
- }
- for (const streamId of streamIds) {
- try {
- const count = this.getStreamViewerCount(streamId);
- const chatActivity = db.getRecentChatActivity(streamId, 5);
- db.insertViewerSnapshot(streamId, count, chatActivity);
- } catch (err) {
- // Non-critical — don't crash the chat server over analytics
- }
- }
- }
-
- /**
- * Push the current users list to all clients in a stream/global.
- * Throttled to avoid flooding on rapid join/leave bursts.
- */
- broadcastUsersList(streamId) {
- const key = `users-${streamId ?? 'global'}`;
- if (this._usersListTimers?.has(key)) return; // already scheduled
- if (!this._usersListTimers) this._usersListTimers = new Map();
- this._usersListTimers.set(key, setTimeout(() => {
- this._usersListTimers.delete(key);
- const users = this.getUserList(streamId);
- const data = JSON.stringify({ type: 'users-list', users });
- for (const [ws, client] of this.clients) {
- if (client.streamId === streamId && ws.readyState === WebSocket.OPEN) {
- try { ws.send(data); } catch {}
- }
- }
- }, 500));
- }
-
- /**
- * Count unique IPs watching a stream (not raw connections).
- * Multiple tabs from the same IP count as one viewer.
- */
- getStreamViewerCount(streamId) {
- const ips = new Set();
- for (const [, client] of this.clients) {
- if (client.streamId === streamId && client.ip) {
- ips.add(client.ip);
- }
- }
- return ips.size;
- }
-
- getTotalConnections() {
- return this.clients.size;
- }
-
- /**
- * Send a DM payload to all WebSocket connections belonging to a given user ID.
- * Used by the DM REST API for real-time delivery.
- */
- sendDm(userId, data) {
- const convId = data?.conversation_id;
- if (convId && !dm.isParticipant(convId, userId)) {
- if (DEBUG_DM_DELIVERY) {
- console.warn(`[DM] blocked delivery to user ${userId} for conversation ${convId} (not a participant)`, data.type, data);
- }
- return;
- }
- const payload = JSON.stringify(data);
- for (const [ws, client] of this.clients) {
- if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {
- if (DEBUG_DM_DELIVERY) {
- console.debug(`[DM] delivering ${data.type} conversation ${convId} to ws user ${client.user.id} (${client.user.username})`);
- }
- try {
- if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) {
- ws.send(payload);
- }
- } catch { /* non-critical */ }
- }
- }
- }
-
- /**
- * Push a profile/identity update to all chat connections belonging to a user.
- * Refreshes cached client.user so subsequent messages use the new info.
- */
- sendUserUpdate(userId, userData) {
- const freshUser = db.getUserById(userId);
- const payload = JSON.stringify({
- type: 'user-updated',
- user: {
- id: userData.id,
- username: userData.username,
- display_name: userData.display_name,
- role: userData.role,
- avatar_url: userData.avatar_url,
- profile_color: userData.profile_color,
- },
- });
- for (const [ws, client] of this.clients) {
- if (client.user?.id === userId && ws.readyState === WebSocket.OPEN) {
- // Refresh cached user object so future messages use new name
- if (freshUser) client.user = freshUser;
- try {
- if (ws.bufferedAmount < MAX_SEND_BACKPRESSURE) ws.send(payload);
- } catch { /* non-critical */ }
- }
- }
- }
-
- /**
- * Broadcast a message to ALL connected chat clients (every stream + global).
- * Used for server-wide announcements (restarts, updates).
- */
- broadcastAll(data) {
- const msg = JSON.stringify(data);
- for (const [ws] of this.clients) {
- if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount <= MAX_SEND_BACKPRESSURE) {
- ws.send(msg);
- }
- }
- }
-
- close() {
- if (this.wss) {
- if (this.heartbeatInterval) {
- clearInterval(this.heartbeatInterval);
- this.heartbeatInterval = null;
- }
- if (this._snapshotInterval) {
- clearInterval(this._snapshotInterval);
- this._snapshotInterval = null;
- }
- if (this._autoDeleteSweepInterval) {
- clearInterval(this._autoDeleteSweepInterval);
- this._autoDeleteSweepInterval = null;
- }
- this.wss.clients.forEach(ws => ws.close());
- this.wss.close();
- }
- }
-
- /**
- * Notify the stream owner that a new IP needs approval.
- * Sends a system message only to the streamer's connection.
- */
- _notifyStreamerPendingIp(streamId, streamOwnerId, username, ip) {
- if (!streamOwnerId) return;
- try {
- for (const [ws, client] of this.clients) {
- if (client.user?.id === streamOwnerId && client.streamId === streamId) {
- this.sendTo(ws, {
- type: 'system',
- message: `⏳ New IP needs approval: ${username} (${ip}). Open Dashboard → Moderation to review.`,
- ip_approval_alert: true,
- });
- break;
- }
- }
- } catch { /* non-critical */ }
- }
-
- /**
- * Donation / goal alert sounds are OpenVibe.Chat's now (roadmap T3): the mapping lives on
- * channel_moderation_settings, which Chat owns, and Chat resolves it from its own row and
- * broadcasts the clip. When Live runs the chat server itself (rollback / dev) there is no Chat to
- * ask, so this is a no-op; with CHAT_AUTHORITY=chat server/monetization/alerts.js sends Chat an `alert` event.
- */
- playAlertSound() { /* Chat plays alerts; see server/monetization/alerts.js */ }
-
- /**
- * Get channel moderation settings for a stream.
- * Caches the channel lookup to avoid repeated DB queries.
- */
- _getChannelChatSettings(streamId) {
- const defaults = {
- slow_mode_seconds: 0, followers_only: 0, emote_only: 0,
- allow_anonymous: 1, links_allowed: 1, account_age_gate_hours: 0,
- caps_percentage_limit: 0, aggressive_filter: 0, max_message_length: 500,
- slur_filter_enabled: 0, slur_filter_use_builtin: 1, slur_filter_terms: '', slur_filter_regexes: '', slur_filter_nudge_message: '', slur_filter_disabled_categories: '[]',
- ip_approval_mode: 0,
- gifs_enabled: 1,
- soundboard_enabled: 1,
- soundboard_allow_pitch: 1,
- soundboard_allow_speed: 1,
- soundboard_banned_ids: '',
- viewer_auto_delete_enabled: 1,
- viewer_delete_all_enabled: 1,
- custom_emotes_enabled: 1,
- custom_sounds_enabled: 1,
- max_sound_seconds: 10,
- uploads_mods_only: 0,
- emote_scale: 100,
- sound_min_speed: 0.5,
- sound_max_speed: 3.0,
- sound_min_pitch_cents: -1200,
- sound_max_pitch_cents: 1200,
- };
- // Derive pitch rate bounds from the configured cents (2^(cents/1200)).
- const finalize = (s) => ({
- ...s,
- sound_min_pitch_rate: Math.pow(2, (Number(s.sound_min_pitch_cents) ?? -1200) / 1200),
- sound_max_pitch_rate: Math.pow(2, (Number(s.sound_max_pitch_cents) ?? 1200) / 1200),
- });
- if (!streamId) return finalize(defaults);
- try {
- const stream = db.getStreamById(streamId);
- if (!stream) return finalize(defaults);
- const channel = stream.channel_id ? db.getChannelById(stream.channel_id) : db.getChannelByUserId(stream.user_id);
- if (!channel) return finalize(defaults);
- return finalize({ ...defaults, ...moderation.settingsSync(channel.id) });
- } catch {
- return finalize(defaults);
- }
- }
-
- /**
- * Log a moderation action from a chat command (/ban, /timeout, /clear, /slowmode).
- * Non-critical — failures are silently ignored.
- */
- logChatModeration(client, actionType, details = {}, targetUserId = null) {
- try {
- const stream = client.streamId ? db.getStreamById(client.streamId) : null;
- const channel = stream?.channel_id
- ? db.getChannelById(stream.channel_id)
- : stream ? db.getChannelByUserId(stream.user_id) : null;
- db.logModerationAction({
- scope_type: channel ? 'channel' : 'site',
- scope_id: channel?.id || undefined,
- actor_user_id: client.user?.id || undefined,
- target_user_id: targetUserId || undefined,
- action_type: actionType,
- details: { stream_id: client.streamId || null, ...details },
- });
- } catch { /* non-critical */ }
- }
-}
-
-// Live-owned data Chat caches, written by Live's own routes (IP approvals, bans): after the write Chat is told
-// to reload it instead of waiting for its cache to expire.
-const OBSERVED_DB = {
- approveIp: (a) => ({ approvals: Number(a[0]) || undefined }),
- revokeIpApproval: (a) => ({ approvals: Number(a[0]) || undefined }),
- forgiveBan: () => ({ bans: true }),
-};
-const PRESENCE_MS = 3000;
-
-/**
- * CHAT_AUTHORITY=chat: OpenVibe.Chat runs the chat server. Live's producers reach it through
- * chat-delivery.js (Chat's typed ingress); this object keeps the rest of the ChatServer surface Live's
- * modules use, without listening or serving:
- * - synchronous reads (connection and viewer counts, slow modes, a connected user's address) come from
- * Chat's presence snapshot (GET /internal/chat/presence), polled every few seconds;
- * - Live's own address / anon-number helpers (call-server, Chat's /internal/chat-effects/anon);
- * - Live's writes to data Chat caches (IP approvals, bans) send Chat a cache hint;
- * - a push a module still makes directly goes to the same ingress (stream, channel, global, all, DM,
- * disconnect, user change); one with no ingress target is dropped and logged, never synthesised or sent
- * to sockets Live does not have (TTS is spoken by Chat from the message's `tts` field).
- * A /ws/chat upgrade that still lands here is told to retry.
- */
-class RemoteChatServer extends ChatServer {
- constructor() {
- super();
- this.remote = true;
- this._presence = { total: 0, streams: {}, slow_mode: {}, users: [], anons: [], at: null };
- this._presenceTimer = null;
- this._dropped = new Set();
- this._observeDb();
- }
-
- _delivery() { return require('./chat-delivery'); }
-
- _observeDb() {
- for (const [fn, hint] of Object.entries(OBSERVED_DB)) {
- const orig = db[fn];
- if (typeof orig !== 'function' || orig._chatObserved) continue;
- const observed = (...args) => {
- const result = orig(...args);
- try { this._delivery().invalidate(hint(args)); } catch { /* non-critical */ }
- return result;
- };
- observed._chatObserved = true;
- db[fn] = observed;
- }
- }
-
- async _pollPresence() {
- try {
- const p = await this._delivery().client.presence();
- if (!p) return;
- this._presence = p;
- this.slowModeByStream.clear();
- for (const [k, v] of Object.entries(p.slow_mode || {})) this.slowModeByStream.set(Number(k), Number(v) || 0);
- } catch { /* keep the last snapshot */ }
- }
-
- init() {
- if (this._presenceTimer) return null;
- this._pollPresence(); // floating-ok: _pollPresence catches and keeps the last snapshot
- this._presenceTimer = setInterval(() => this._pollPresence(), PRESENCE_MS);
- if (this._presenceTimer.unref) this._presenceTimer.unref();
- console.log('[Chat] CHAT_AUTHORITY=chat — chat runs in OpenVibe.Chat; Live delivers through its ingress');
- return null;
- }
-
- handleUpgrade(req, socket) {
- // nginx sends /ws/chat to Chat; a socket that still lands here is told to retry.
- try { socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\nConnection: close\r\n\r\n'); } catch { /* */ }
- try { socket.destroy(); } catch { /* */ }
- return true;
- }
-
- close() {
- if (this._presenceTimer) clearInterval(this._presenceTimer);
- this._presenceTimer = null;
- }
-
- // ── Pushes: Chat's ingress, or dropped and logged ──
- _drop(name) {
- if (this._dropped.has(name)) return;
- this._dropped.add(name);
- console.warn(`[Chat] ${name} has no Chat ingress target and was dropped; deliver through chat-delivery.js`);
- }
-
- // Guarded so a flipped CHAT_AUTHORITY can never bounce a push between this object and the seam.
- _push(name, fn) { const d = this._delivery(); return d.ingress() ? fn(d) : this._drop(name); }
- broadcastToStream(streamId, data) { return this._push('broadcastToStream', (d) => d.event({ kind: 'stream', id: streamId }, data)); }
- broadcastToChannelRoom(channelUserId, streamId, data) { return this._push('broadcastToChannelRoom', (d) => d.event({ kind: 'channel', id: channelUserId }, data)); }
- broadcastGlobal(data) { return this._push('broadcastGlobal', (d) => d.event({ kind: 'global' }, data)); }
- broadcastAll(data) { return this._push('broadcastAll', (d) => d.event({ kind: 'all' }, data)); }
- sendDm(userId, data) { return this._push('sendDm', (d) => d.event({ kind: 'user', id: userId }, data)); }
- disconnectUser(opts) { return this._push('disconnectUser', (d) => d.disconnect(opts)); }
- sendUserUpdate(userId, u) {
- const id = Number(userId) || undefined;
- const userData = u ? { id, username: u.username, display_name: u.display_name || null, role: u.role || null, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : undefined;
- return this._push('sendUserUpdate', (d) => d.invalidate({ user: id, user_data: userData }));
- }
- forwardToGlobal() { this._drop('forwardToGlobal'); }
- forwardToGlobalByChannel() { this._drop('forwardToGlobalByChannel'); }
- forwardToStreamerRooms() { this._drop('forwardToStreamerRooms'); }
- broadcastToOwnerStreams() { this._drop('broadcastToOwnerStreams'); }
- triggerChannelSound() { this._drop('triggerChannelSound'); }
- sendToConn() { this._drop('sendToConn'); }
- synthesizeAndBroadcastTTS() { this._drop('synthesizeAndBroadcastTTS'); return Promise.resolve(); }
- broadcastUserCount() { /* Chat's */ }
- broadcastUsersList() { /* Chat's */ }
-
- // ── Synchronous reads (presence snapshot) ──
- getTotalConnections() { return Number(this._presence.total) || 0; }
- getStreamViewerCount(streamId) { return Number((this._presence.streams || {})[streamId]) || 0; }
- getConnectedUserIp(userId) {
- const hit = (this._presence.users || []).find((u) => u.user_id === userId);
- return hit ? hit.ip : null;
- }
- findClientByAnonId(anonId, streamId) {
- // Same match as ChatServer.findClientByAnonId: the socket's stream must equal streamId.
- const hit = (this._presence.anons || []).find((a) => a.anon_id === anonId && (a.stream_id ?? null) === streamId);
- return hit ? { anonId: hit.anon_id, ip: hit.ip, streamId: hit.stream_id || null, user: null } : null;
- }
- getUserList() { return { logged: [], anonCount: 0 }; }
- getAnonIdForConnection(ip, streamId = null) {
- const key = this.normalizeIp(ip);
- const hit = (this._presence.anons || []).find((a) => a.ip === key && (streamId == null || (a.stream_id || null) === streamId));
- return hit ? hit.anon_id : this.getAnonIdForIp(key);
- }
-
- /** The anon number for an address (Network's unified resolve, else Live's table) — for Chat. */
- async resolveAnon(ip) {
- const key = this.normalizeIp(ip);
- const num = await this._resolveUnifiedAnonNum(key);
- return { anon_number: num, first_seen: db.getAnonFirstSeen(key) };
- }
-}
-
-module.exports = require('./chat-authority').isRemote() ? new RemoteChatServer() : new ChatServer();
@@ -30,11 +30,6 @@ const SETTING = 'deploy_last_announced';
@@ -30,11 +30,6 @@ const SETTING = 'deploy_last_announced';
// A card covers at most 3 hours of deploys, so its time range stays readable.
// A card covers at most 3 hours of deploys, so its time range stays readable.
const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;
const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;
const MAX_COMMITS = 40;
const MAX_COMMITS = 40;
-const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
-
-// The notice for THIS boot, kept for a while so clients that reconnect late still get it exactly once.
-const REPLAY_MS = 15 * 60 * 1000;
-let _live = null; // { payload, until, sent: WeakSet }
const git = (args, timeout = 5000) => new Promise((resolve) => {
const git = (args, timeout = 5000) => new Promise((resolve) => {
// -c safe.directory=*: the release layout's worktrees are root-owned and the service user only reads them.
// -c safe.directory=*: the release layout's worktrees are root-owned and the service user only reads them.
@@ -86,10 +81,10 @@ function persist(db, commits) {
@@ -86,10 +81,10 @@ function persist(db, commits) {
}
}
/**
/**
- * Announce this boot's new commits. Safe to call once after the chat server is up.
+ * Announce this boot's new commits. Safe to call once after boot.
* @returns {Promise<{ announced: number, event_id?: string|null }>}
* @returns {Promise<{ announced: number, event_id?: string|null }>}
*/
*/
-async function announce({ db, chatServer, log = console }) {
+async function announce({ db, log = console }) {
const { head, previous, commits } = await newCommits(db);
const { head, previous, commits } = await newCommits(db);
if (!head || !commits.length) return { announced: 0 };
if (!head || !commits.length) return { announced: 0 };
@@ -124,30 +119,7 @@ async function announce({ db, chatServer, log = console }) {
@@ -124,30 +119,7 @@ async function announce({ db, chatServer, log = console }) {
catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
catch (err) { log.warn('[Deploy notice] not saved:', err.message); return { announced: 0 }; }
if (outbox) outbox.kick();
if (outbox) outbox.kick();
- const payload = JSON.stringify({ type: 'update', kind: 'deploy', id: saved.id, fresh: commits.map(c => c.hash), url: '/updates', timestamp: saved.meta.updated_at, ...saved.meta });
- const sent = new WeakSet();
- _live = { payload, until: Date.now() + REPLAY_MS, sent };
- const push = () => {
- let n = 0;
- for (const [ws] of chatServer.clients) {
- if (sent.has(ws) || ws.readyState !== 1 || ws.bufferedAmount > 256 * 1024) continue;
- try { ws.send(payload); sent.add(ws); n++; } catch { /* socket went away */ }
- }
- return n;
- };
- ATTEMPTS_MS.forEach((ms, i) => { const t = setTimeout(() => { const n = push(); if (n) log.log(`[Deploy notice] ${commits.length} commit(s) → ${n} client(s) (pass ${i + 1})`); }, ms); if (t.unref) t.unref(); });
return { announced: commits.length, event_id: eventId };
return { announced: commits.length, event_id: eventId };
}
}
-/**
- * Called when a chat client finishes joining: if this boot shipped something and this socket has not had
- * it, send it now. Covers clients whose reconnect backoff outlasted the broadcast passes, a chat server
- * that came up late, and tabs that were asleep. The card is keyed by row id, so a repeat cannot duplicate.
- */
-function replayTo(ws) {
- if (!_live || Date.now() > _live.until || _live.sent.has(ws)) return false;
- try { if (ws.readyState === 1) { ws.send(_live.payload); _live.sent.add(ws); return true; } } catch { /* socket went away */ }
- return false;
-}
-
-module.exports = { replayTo, announce, newCommits, persist, plainText, parseLog, SETTING };
+module.exports = { announce, newCommits, persist, plainText, parseLog, SETTING };
@@ -270,13 +270,13 @@ effectsRouter.use((req, res, next) => {
@@ -270,13 +270,13 @@ effectsRouter.use((req, res, next) => {
next();
next();
});
});
-const chatServer = () => require('./chat-server');
+const chatDelivery = () => require('./chat-delivery');
const actorOf = (id) => (id ? db.getUserById(id) : null);
const actorOf = (id) => (id ? db.getUserById(id) : null);
effectsRouter.post('/anon', async (req, res) => {
effectsRouter.post('/anon', async (req, res) => {
const ip = String(req.body?.ip || '');
const ip = String(req.body?.ip || '');
if (!ip) return fail(res, 400, 'ip required');
if (!ip) return fail(res, 400, 'ip required');
- try { res.json(await chatServer().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }
+ try { res.json(await chatDelivery().resolveAnon(ip)); } catch (err) { fail(res, 500, err.message); }
});
});
effectsRouter.post('/ip-log', (req, res) => {
effectsRouter.post('/ip-log', (req, res) => {
@@ -46,7 +46,7 @@ function snapshot(services = {}) {
@@ -46,7 +46,7 @@ function snapshot(services = {}) {
memoryMb: { rss: mb(mem.rss), heapUsed: mb(mem.heapUsed), heapTotal: mb(mem.heapTotal), external: mb(mem.external) },
memoryMb: { rss: mb(mem.rss), heapUsed: mb(mem.heapUsed), heapTotal: mb(mem.heapTotal), external: mb(mem.external) },
eventLoop: { lastMinute: lastWindow },
eventLoop: { lastMinute: lastWindow },
connections: {
connections: {
- chat: safe(() => services.chatServer.getTotalConnections()),
+ chat: safe(() => services.chatDelivery.getTotalConnections()),
broadcast: safe(() => services.broadcastServer.clients.size),
broadcast: safe(() => services.broadcastServer.clients.size),
call: safe(() => services.callServer.clients.size),
call: safe(() => services.callServer.clients.size),
liveEventStreams: safe(() => require('./streaming/live-events').clientCount()),
liveEventStreams: safe(() => require('./streaming/live-events').clientCount()),
@@ -99,7 +99,7 @@ const webrtcSFU = require('./streaming/webrtc-sfu');
@@ -99,7 +99,7 @@ const webrtcSFU = require('./streaming/webrtc-sfu');
const rtmpServer = require('./streaming/rtmp-server');
const rtmpServer = require('./streaming/rtmp-server');
// Real-time
// Real-time
-const chatServer = require('./chat/chat-server');
+const chatDelivery = require('./chat/chat-delivery');
const controlServer = require('./controls/control-server');
const controlServer = require('./controls/control-server');
const broadcastServer = require('./streaming/broadcast-server');
const broadcastServer = require('./streaming/broadcast-server');
const callServer = require('./streaming/call-server');
const callServer = require('./streaming/call-server');
@@ -198,7 +198,7 @@ const stopper = gracefulStop({
@@ -198,7 +198,7 @@ const stopper = gracefulStop({
try { restreamManager.stopAll(); } catch { /* */ }
try { restreamManager.stopAll(); } catch { /* */ }
try { recorder.stopAll(); } catch { /* */ }
try { recorder.stopAll(); } catch { /* */ }
try { callServer.close(); } catch { /* */ }
try { callServer.close(); } catch { /* */ }
- try { chatServer.close(); } catch { /* */ }
+ try { chatDelivery.close(); } catch { /* */ }
try { controlServer.close(); } catch { /* */ }
try { controlServer.close(); } catch { /* */ }
try { broadcastServer.close(); } catch { /* */ }
try { broadcastServer.close(); } catch { /* */ }
try { jsmpegRelay.closeAll(); } catch { /* */ }
try { jsmpegRelay.closeAll(); } catch { /* */ }
@@ -240,7 +240,7 @@ function releaseEnv() {
@@ -240,7 +240,7 @@ function releaseEnv() {
// answers direct loopback callers only; through nginx it is a 404 (server/web/observability.js).
// answers direct loopback callers only; through nginx it is a 404 (server/web/observability.js).
const observability = require('./web/observability');
const observability = require('./web/observability');
const { registry: metricsRegistry } = observability.mountMetrics(app, { release });
const { registry: metricsRegistry } = observability.mountMetrics(app, { release });
-const vibeCodingPublishServer = new VibeCodingPublishServer(chatServer, db);
+const vibeCodingPublishServer = new VibeCodingPublishServer(db);
function normalizeOrigin(origin) {
function normalizeOrigin(origin) {
if (!origin || typeof origin !== 'string') return null;
if (!origin || typeof origin !== 'string') return null;
@@ -820,7 +820,7 @@ app.get('/api/health', (req, res) => {
@@ -820,7 +820,7 @@ app.get('/api/health', (req, res) => {
name: 'OpenVibe.Live',
name: 'OpenVibe.Live',
version: '1.0.0',
version: '1.0.0',
uptime: process.uptime(),
uptime: process.uptime(),
- chat_connections: chatServer.getTotalConnections(),
+ chat_connections: chatDelivery.getTotalConnections(),
});
});
});
});
@@ -859,7 +859,7 @@ app.get('/api/ready', readiness.handler);
@@ -859,7 +859,7 @@ app.get('/api/ready', readiness.handler);
observability.registerDomainGauges(metricsRegistry, {
observability.registerDomainGauges(metricsRegistry, {
liveStreams: () => db.getLiveStreams().length,
liveStreams: () => db.getLiveStreams().length,
- wsServers: { chat: chatServer, broadcast: broadcastServer, control: controlServer, call: callServer },
+ wsServers: { broadcast: broadcastServer, control: controlServer, call: callServer },
outboxStatus: () => require('./events/stream-events').status(),
outboxStatus: () => require('./events/stream-events').status(),
});
});
@@ -927,7 +927,7 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re
@@ -927,7 +927,7 @@ app.post('/api/admin/broadcast', requireAuth, permissions.requireAdmin, (req, re
url,
url,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
});
});
- res.json({ ok: true, clients: chatServer.getTotalConnections() });
+ res.json({ ok: true, clients: chatDelivery.getTotalConnections() });
} catch (err) {
} catch (err) {
res.status(500).json({ error: err.message });
res.status(500).json({ error: err.message });
}
}
@@ -1139,7 +1139,7 @@ server.on('upgrade', (req, socket, head) => {
@@ -1139,7 +1139,7 @@ server.on('upgrade', (req, socket, head) => {
// Block banned IPs from WebSocket connections
// Block banned IPs from WebSocket connections
try {
try {
- const wsIp = chatServer.getClientIp(req);
+ const wsIp = chatDelivery.getClientIp(req);
if (db.isIpBanned(wsIp, null)) {
if (db.isIpBanned(wsIp, null)) {
// Admins pass network bans (shared home network) — see isBanExemptAdmin.
// Admins pass network bans (shared home network) — see isBanExemptAdmin.
let exempt = false;
let exempt = false;
@@ -1148,9 +1148,8 @@ server.on('upgrade', (req, socket, head) => {
@@ -1148,9 +1148,8 @@ server.on('upgrade', (req, socket, head) => {
}
}
} catch (e) { /* non-critical — allow through on DB error */ }
} catch (e) { /* non-critical — allow through on DB error */ }
- if (url.startsWith('/ws/chat')) {
- chatServer.handleUpgrade(req, socket, head);
- } else if (url.startsWith('/ws/vibe-coding/publish')) {
+ // /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.
+ if (url.startsWith('/ws/vibe-coding/publish')) {
vibeCodingPublishServer.handleUpgrade(req, socket, head);
vibeCodingPublishServer.handleUpgrade(req, socket, head);
} else if (url.startsWith('/ws/broadcast')) {
} else if (url.startsWith('/ws/broadcast')) {
broadcastServer.handleUpgrade(req, socket, head);
broadcastServer.handleUpgrade(req, socket, head);
@@ -1228,12 +1227,12 @@ async function start() {
@@ -1228,12 +1227,12 @@ async function start() {
}
}
// 3. Initialize chat server
// 3. Initialize chat server
- chatServer.init(server);
+ chatDelivery.init();
vibeCodingPublishServer.init(server);
vibeCodingPublishServer.init(server);
// 3b. Initialize breaking news service
// 3b. Initialize breaking news service
const newsService = require('./news/news-service');
const newsService = require('./news/news-service');
- newsService.setChatServer(chatServer);
+ newsService.setChatServer(chatDelivery);
newsService.start();
newsService.start();
// 4. Initialize control server
// 4. Initialize control server
@@ -1461,7 +1460,7 @@ async function start() {
@@ -1461,7 +1460,7 @@ async function start() {
// Deploy notice in chat: only commits not announced before, folded into one rolling message
// Deploy notice in chat: only commits not announced before, folded into one rolling message
// (server/chat/deploy-notice.js). A restart with no new code says nothing.
// (server/chat/deploy-notice.js). A restart with no new code says nothing.
- require('./chat/deploy-notice').announce({ db, chatServer }).catch((err) => console.warn('[Deploy notice] failed:', err.message));
+ require('./chat/deploy-notice').announce({ db }).catch((err) => console.warn('[Deploy notice] failed:', err.message));
});
});
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
// 8. Start stale stream heartbeat cleanup (every 60 seconds)
@@ -19,7 +19,6 @@ const https = require('https');
@@ -19,7 +19,6 @@ const https = require('https');
const http = require('http');
const http = require('http');
const db = require('../db/database');
const db = require('../db/database');
-const chatServer = require('../chat/chat-server');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
const RECONNECT_BASE_MS = 5000;
const RECONNECT_BASE_MS = 5000;
@@ -433,16 +432,16 @@ class ChatRelayService {
@@ -433,16 +432,16 @@ class ChatRelayService {
if (result?.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
if (result?.lastInsertRowid) chatMsg.id = Number(result.lastInsertRowid);
} catch {}
} catch {}
- chatServer.broadcastToStream(bridge.streamId, chatMsg);
+ delivery.broadcastToStream(bridge.streamId, chatMsg);
this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'ai');
this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'ai');
// Also surface on the global / username-only overlay (tags stream_channel)
// Also surface on the global / username-only overlay (tags stream_channel)
- try { chatServer.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }
+ try { delivery.forwardToGlobal(bridge.streamId, chatMsg); } catch { /* non-critical */ }
// And to viewers of the streamer's other live slots (cross-slot chat)
// And to viewers of the streamer's other live slots (cross-slot chat)
- try { chatServer.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }
+ try { delivery.forwardToStreamerRooms(bridge.streamId, chatMsg); } catch { /* non-critical */ }
// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
// Feed relayed platform chat (Kick/Twitch/YouTube) into server-side TTS
try {
try {
- chatServer.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`, null, chatMsg.id ? `m${chatMsg.id}` : null);
+ delivery.synthesizeAndBroadcastTTS(bridge.streamId, prefixedUsername, chatMsg.message, null, bridge.platform, `${bridge.platform}:${prefixedUsername}`, null, chatMsg.id ? `m${chatMsg.id}` : null);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'powerchat');
this._relayFollowUps(bridge, username, prefixedUsername, chatMsg.message, extras, chatMsg.id || null, 'powerchat');
@@ -454,7 +453,7 @@ class ChatRelayService {
@@ -454,7 +453,7 @@ class ChatRelayService {
const chatterKey = `ext:${prefixedUsername}`;
const chatterKey = `ext:${prefixedUsername}`;
if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
if (db.isFirstChatInChannel(chatterKey, stream.user_id)) {
db.recordFirstChat(chatterKey, stream.user_id);
db.recordFirstChat(chatterKey, stream.user_id);
- chatServer.broadcastToStream(bridge.streamId, {
+ delivery.broadcastToStream(bridge.streamId, {
type: 'system',
type: 'system',
message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
message: `Welcome ${username} from ${PLATFORM_LABELS[bridge.platform] || bridge.platform}! 👋`,
timestamp: new Date().toISOString(),
timestamp: new Date().toISOString(),
@@ -93,15 +93,14 @@ function _testFulfillmentAllowed() {
@@ -93,15 +93,14 @@ function _testFulfillmentAllowed() {
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
// ── Donation handling — mirrors POST /api/funds/donate ───────────────────────
// A persisted chat line plus its live card: one Chat ingress call with CHAT_AUTHORITY=chat (Chat persists and
// A persisted chat line plus its live card: one Chat ingress call with CHAT_AUTHORITY=chat (Chat persists and
// shows it; `mirror` also puts it in global chat), else the legacy card broadcast(s) and a local save.
// shows it; `mirror` also puts it in global chat), else the legacy card broadcast(s) and a local save.
-function _chatLine(chatServer, { frame, line, mirror = false, key }) {
+function _chatLine({ frame, line, mirror = false, key }) {
if (delivery.ingress()) { delivery.message({ ...line, mirror, key }); return; }
if (delivery.ingress()) { delivery.message({ ...line, mirror, key }); return; }
- chatServer.broadcastToChannelRoom(line.channel_user_id, line.stream_id, frame);
- if (mirror) { try { chatServer.broadcastGlobal({ ...frame, global: true, channel_user_id: line.channel_user_id }); } catch { /* */ } }
+ delivery.broadcastToChannelRoom(line.channel_user_id, line.stream_id, frame);
+ if (mirror) { try { delivery.broadcastGlobal({ ...frame, global: true, channel_user_id: line.channel_user_id }); } catch { /* */ } }
db.saveChatMessage(line);
db.saveChatMessage(line);
}
}
function _handleDonation(userId, data) {
function _handleDonation(userId, data) {
- const chatServer = require('../chat/chat-server');
const alerts = require('../monetization/alerts');
const alerts = require('../monetization/alerts');
const openvibeBucks = require('../monetization/vibes');
const openvibeBucks = require('../monetization/vibes');
@@ -129,7 +128,7 @@ function _handleDonation(userId, data) {
@@ -129,7 +128,7 @@ function _handleDonation(userId, data) {
};
};
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
const eventKey = data.eventId ? `powerchat:${data.eventId}` : undefined;
try {
try {
- _chatLine(chatServer, {
+ _chatLine({
frame: donationEvent, mirror: true, key: eventKey,
frame: donationEvent, mirror: true, key: eventKey,
line: {
line: {
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
@@ -141,7 +140,7 @@ function _handleDonation(userId, data) {
@@ -141,7 +140,7 @@ function _handleDonation(userId, data) {
} catch { /* */ }
} catch { /* */ }
// 2) Donation sound.
// 2) Donation sound.
- try { alerts.playAlertSound(chatServer, userId, streamId, 'donation'); } catch { /* */ }
+ try { alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }
// 3) Goal progress + 4) goal reached.
// 3) Goal progress + 4) goal reached.
if (goalResult && goalResult.goal) {
if (goalResult && goalResult.goal) {
@@ -150,7 +149,7 @@ function _handleDonation(userId, data) {
@@ -150,7 +149,7 @@ function _handleDonation(userId, data) {
if (goalResult && goalResult.reached) {
if (goalResult && goalResult.reached) {
const g = goalResult.goal;
const g = goalResult.goal;
try {
try {
- _chatLine(chatServer, {
+ _chatLine({
frame: { type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts },
frame: { type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts },
key: eventKey && `${eventKey}:goal`,
key: eventKey && `${eventKey}:goal`,
line: {
line: {
@@ -161,7 +160,7 @@ function _handleDonation(userId, data) {
@@ -161,7 +160,7 @@ function _handleDonation(userId, data) {
},
},
});
});
} catch { /* */ }
} catch { /* */ }
- try { alerts.playAlertSound(chatServer, userId, streamId, 'goal'); } catch { /* */ }
+ try { alerts.playAlertSound(userId, streamId, 'goal'); } catch { /* */ }
}
}
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
console.log(`[PowerChat] Donation: ${amount} bucks to user ${userId} from ${donor}${goalResult && goalResult.reached ? ' (goal reached!)' : ''}`);
@@ -169,11 +168,10 @@ function _handleDonation(userId, data) {
@@ -169,11 +168,10 @@ function _handleDonation(userId, data) {
// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).
// A membership/sub — surface as a chat event (no OpenVibe.Live sub system to credit).
function _handleSubscription(userId, data) {
function _handleSubscription(userId, data) {
- const chatServer = require('../chat/chat-server');
const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);
const name = String(data.subscriberName || data.donorName || 'Someone').slice(0, 80);
const ts = new Date().toISOString();
const ts = new Date().toISOString();
try {
try {
- _chatLine(chatServer, {
+ _chatLine({
frame: { type: 'donation', username: name, amount: 0, message: 'subscribed via PowerChat', source: 'powerchat-sub', timestamp: ts },
frame: { type: 'donation', username: name, amount: 0, message: 'subscribed via PowerChat', source: 'powerchat-sub', timestamp: ts },
key: data.eventId ? `powerchat:${data.eventId}` : undefined,
key: data.eventId ? `powerchat:${data.eventId}` : undefined,
line: {
line: {
@@ -188,9 +186,8 @@ function _handleSubscription(userId, data) {
@@ -188,9 +186,8 @@ function _handleSubscription(userId, data) {
// A lightweight chat notice for non-money events (follow / host / points redeem).
// A lightweight chat notice for non-money events (follow / host / points redeem).
function _handleNotice(userId, message, kind) {
function _handleNotice(userId, message, kind) {
try {
try {
- const chatServer = require('../chat/chat-server');
- const ts = new Date().toISOString();
- _chatLine(chatServer, {
+ const ts = new Date().toISOString();
+ _chatLine({
frame: { type: 'system', message, source: 'powerchat', kind, timestamp: ts },
frame: { type: 'system', message, source: 'powerchat', kind, timestamp: ts },
line: {
line: {
stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',
stream_id: null, channel_user_id: userId, user_id: null, username: 'PowerChat',
@@ -287,7 +284,6 @@ function processEvent(envelope) {
@@ -287,7 +284,6 @@ function processEvent(envelope) {
// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;
// permanently crediting a goal. Broadcasts the donation chat event + plays the alert sound;
// if there's an active goal it also sends a transient goal-update preview (not persisted).
// if there's an active goal it also sends a transient goal-update preview (not persisted).
function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', message = 'Test tip ✨' } = {}) {
function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', message = 'Test tip ✨' } = {}) {
- const chatServer = require('../chat/chat-server');
const alerts = require('../monetization/alerts');
const alerts = require('../monetization/alerts');
// Vibes are bit-style: $1 = 100 bucks, so the test dollar amount → bucks ×100.
// Vibes are bit-style: $1 = 100 bucks, so the test dollar amount → bucks ×100.
const amount = Math.max(1, Math.round(amountUsd * 100));
const amount = Math.max(1, Math.round(amountUsd * 100));
@@ -305,7 +301,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
@@ -305,7 +301,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
// actually test what the streamer is checking — that the alert lands in chat AND
// actually test what the streamer is checking — that the alert lands in chat AND
// survives a refresh. This was the only donation path that broadcast without saving.
// survives a refresh. This was the only donation path that broadcast without saving.
try {
try {
- _chatLine(chatServer, {
+ _chatLine({
frame: testEvent,
frame: testEvent,
line: {
line: {
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
stream_id: streamId, channel_user_id: userId, user_id: null, username: donor,
@@ -315,7 +311,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
@@ -315,7 +311,7 @@ function simulateDonation(userId, { amountUsd = 5, donor = 'Test Tipper', messag
},
},
});
});
} catch (e) { console.warn('[PowerChat] test tip not saved to history:', e.message); }
} catch (e) { console.warn('[PowerChat] test tip not saved to history:', e.message); }
- try { alerts.playAlertSound(chatServer, userId, streamId, 'donation'); } catch { /* */ }
+ try { alerts.playAlertSound(userId, streamId, 'donation'); } catch { /* */ }
// Transient goal-progress preview (does NOT persist — reload restores the real number).
// Transient goal-progress preview (does NOT persist — reload restores the real number).
try {
try {
@@ -3,7 +3,6 @@ const crypto = require('crypto');
@@ -3,7 +3,6 @@ const crypto = require('crypto');
const WebSocket = require('ws');
const WebSocket = require('ws');
const db = require('../db/database');
const db = require('../db/database');
-const chatServer = require('../chat/chat-server');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
const { authenticateWs } = require('../auth/auth');
const { authenticateWs } = require('../auth/auth');
@@ -689,7 +688,7 @@ class RobotStreamerService {
@@ -689,7 +688,7 @@ class RobotStreamerService {
return;
return;
}
}
try {
try {
- chatServer.triggerChannelSound(
+ delivery.triggerChannelSound(
null,
null,
{ streamId: stream.id, user: null, anonId: null, ip: null },
{ streamId: stream.id, user: null, anonId: null, ip: null },
stream, scmd, parts.slice(1),
stream, scmd, parts.slice(1),
@@ -739,16 +738,16 @@ class RobotStreamerService {
@@ -739,16 +738,16 @@ class RobotStreamerService {
if (result?.lastInsertRowid) mirrored.id = Number(result.lastInsertRowid);
if (result?.lastInsertRowid) mirrored.id = Number(result.lastInsertRowid);
} catch {}
} catch {}
- chatServer.broadcastToStream(stream.id, mirrored);
+ delivery.broadcastToStream(stream.id, mirrored);
rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'ai');
rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'ai');
// Also surface on the global / username-only overlay (tags stream_channel)
// Also surface on the global / username-only overlay (tags stream_channel)
- try { chatServer.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }
+ try { delivery.forwardToGlobal(stream.id, mirrored); } catch { /* non-critical */ }
// And to viewers of the streamer's other live slots (cross-slot chat)
// And to viewers of the streamer's other live slots (cross-slot chat)
- try { chatServer.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }
+ try { delivery.forwardToStreamerRooms(stream.id, mirrored); } catch { /* non-critical */ }
// Feed relayed RS chat into server-side TTS (same path as native chat)
// Feed relayed RS chat into server-side TTS (same path as native chat)
try {
try {
- chatServer.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`, null, mirrored.id ? `m${mirrored.id}` : null);
+ delivery.synthesizeAndBroadcastTTS(stream.id, username, mirrored.message, null, 'rs', `rs:${username}`, null, mirrored.id ? `m${mirrored.id}` : null);
} catch { /* non-critical */ }
} catch { /* non-critical */ }
rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'powerchat');
rsFollowUps(stream, username, rawUsername, mirrored.message, data.avatar, mirrored.id || null, 'powerchat');
@@ -86,7 +86,7 @@ async function _sendOne(clipId) {
@@ -86,7 +86,7 @@ async function _sendOne(clipId) {
}
}
const saved = db.saveChatMessage(line);
const saved = db.saveChatMessage(line);
payload.id = saved && saved.lastInsertRowid;
payload.id = saved && saved.lastInsertRowid;
- require('../chat/chat-server').broadcastToChannelRoom(ownerId, streamId, payload);
+ delivery.broadcastToChannelRoom(ownerId, streamId, payload);
} catch (e) {
} catch (e) {
console.warn('[ClipNotify] send failed:', e.message);
console.warn('[ClipNotify] send failed:', e.message);
}
}
@@ -896,10 +896,9 @@ class MediaQueue {
@@ -896,10 +896,9 @@ class MediaQueue {
const delivery = require('../chat/chat-delivery');
const delivery = require('../chat/chat-delivery');
// Chat's ingress fans a media frame out to the owner's live streams itself.
// Chat's ingress fans a media frame out to the owner's live streams itself.
if (delivery.ingress()) { delivery.event({ kind: 'owner-streams', id: streamerId }, payload); return; }
if (delivery.ingress()) { delivery.event({ kind: 'owner-streams', id: streamerId }, payload); return; }
- const chatServer = require('../chat/chat-server');
const streams = db.getLiveStreamsByUserId(streamerId) || [];
const streams = db.getLiveStreamsByUserId(streamerId) || [];
for (const stream of streams) {
for (const stream of streams) {
- chatServer.broadcastToStream(stream.id, payload);
+ delivery.broadcastToStream(stream.id, payload);
}
}
} catch {
} catch {
// optional
// optional
@@ -12,19 +12,13 @@
@@ -12,19 +12,13 @@
/**
/**
* Ask Chat to play a streamer's alert sound to their viewers (channel-wide, so it reaches every slot
* Ask Chat to play a streamer's alert sound to their viewers (channel-wide, so it reaches every slot
- * plus the offline room). kind: 'donation' | 'goal'. No-op if Chat is not the authority or nothing is
- * configured. Chat answers `played:false` (never an error) when the channel has no sound.
+ * plus the offline room). kind: 'donation' | 'goal'. Chat answers `played:false` (never an error) when
+ * the channel has no sound; outside chat mode the event is dropped and logged by chat-delivery.js.
*/
*/
-function playAlertSound(chatServer, streamerId, streamId, kind) {
+function playAlertSound(streamerId, streamId, kind) {
try {
try {
- const delivery = require('../chat/chat-delivery');
- if (delivery.ingress()) {
- // An `alert` event on Chat's typed ingress.
- delivery.event({ kind: 'channel', id: streamerId }, { type: 'alert', streamerId: Number(streamerId), streamId: streamId ? Number(streamId) : null, kind: kind === 'goal' ? 'goal' : 'donation' });
- return;
- }
- if (!chatServer || typeof chatServer.playAlertSound !== 'function') return;
- chatServer.playAlertSound(streamerId, streamId, kind === 'goal' ? 'goal' : 'donation');
+ // An `alert` event on Chat's typed ingress.
+ require('../chat/chat-delivery').event({ kind: 'channel', id: streamerId }, { type: 'alert', streamerId: Number(streamerId), streamId: streamId ? Number(streamId) : null, kind: kind === 'goal' ? 'goal' : 'donation' });
} catch { /* non-critical */ }
} catch { /* non-critical */ }
}
}
@@ -88,7 +88,6 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -88,7 +88,6 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
// A request the browser repeated with the same Idempotency-Key: already celebrated.
// A request the browser repeated with the same Idempotency-Key: already celebrated.
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
if (result.replayed) return res.json({ success: true, amount: result.amount, balance: result.balance, goal_reached: false });
- const chatServer = require('../chat/chat-server');
const alerts = require('./alerts');
const alerts = require('./alerts');
const donorUser = db.getUserById(req.user.id);
const donorUser = db.getUserById(req.user.id);
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
const donor = donorUser?.display_name || donorUser?.username || 'Someone';
@@ -112,15 +111,15 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -112,15 +111,15 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
// Chat persists the line and shows it in the channel and (mirror) global chat in one call.
// Chat persists the line and shows it in the channel and (mirror) global chat in one call.
delivery.message({ ...donationLine, mirror: true, key: result.transactionId ? `donation:${result.transactionId}` : undefined });
delivery.message({ ...donationLine, mirror: true, key: result.transactionId ? `donation:${result.transactionId}` : undefined });
} else {
} else {
- chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, donationEvent);
+ delivery.broadcastToChannelRoom(streamer_id, stream_id || null, donationEvent);
// Tips are a site-wide event worth celebrating, so mirror them into global chat
// Tips are a site-wide event worth celebrating, so mirror them into global chat
// instead of confining them to the channel that received them.
// instead of confining them to the channel that received them.
- try { chatServer.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }
+ try { delivery.broadcastGlobal({ ...donationEvent, global: true, channel_user_id: streamer_id }); } catch { /* */ }
try { db.saveChatMessage(donationLine); } catch { /* non-critical */ }
try { db.saveChatMessage(donationLine); } catch { /* non-critical */ }
}
}
// 2) Donation sound (streamer-configured).
// 2) Donation sound (streamer-configured).
- alerts.playAlertSound(chatServer, streamer_id, stream_id, 'donation');
+ alerts.playAlertSound(streamer_id, stream_id, 'donation');
// Mirror the donation onto the streamer's PowerChat overlay as a monetary tip
// Mirror the donation onto the streamer's PowerChat overlay as a monetary tip
// (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat
// (tips:write). Vibes ARE the declared currency units (100 = $1); PowerChat
@@ -152,12 +151,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
@@ -152,12 +151,12 @@ router.post('/donate', requireAuth, money.guardWrite, async (req, res) => {
};
};
if (delivery.ingress()) delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });
if (delivery.ingress()) delivery.message({ ...goalLine, key: result.transactionId ? `goal:${result.transactionId}` : undefined });
else {
else {
- chatServer.broadcastToChannelRoom(streamer_id, stream_id || null, {
+ delivery.broadcastToChannelRoom(streamer_id, stream_id || null, {
type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts,
type: 'goal-reached', goal: publicGoal(g), by: donor, timestamp: ts,
});
});
try { db.saveChatMessage(goalLine); } catch { /* non-critical */ }
try { db.saveChatMessage(goalLine); } catch { /* non-critical */ }
}
}
- alerts.playAlertSound(chatServer, streamer_id, stream_id, 'goal');
+ alerts.playAlertSound(streamer_id, stream_id, 'goal');
}
}
const balance = money.onBilling() ? result.balance : db.getUserById(req.user.id).openvibe_bucks_balance;
const balance = money.onBilling() ? result.balance : db.getUserById(req.user.id).openvibe_bucks_balance;
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
*
*
* Architecture:
* Architecture:
* news-service (this) ←→ sources (reddit, newsapi, rss, ...)
* news-service (this) ←→ sources (reddit, newsapi, rss, ...)
- * → chatServer.broadcastToStream() / broadcastGlobal()
+ * → chat-delivery.js event() (Chat's ingress)
*
*
* Settings stored in SQLite: news_settings table (per-user/global).
* Settings stored in SQLite: news_settings table (per-user/global).
* Headline dedup via in-memory Set (cleared on restart, bounded to 500).
* Headline dedup via in-memory Set (cleared on restart, bounded to 500).
@@ -202,12 +202,7 @@ function announce(recap) {
@@ -202,12 +202,7 @@ function announce(recap) {
const g = recap.write.grade;
const g = recap.write.grade;
const frame = { type: 'system', message: `📋 After-show report for "${recap.stream.title}" is in — grade ${g}: ${recap.write.headline}. Read it: /recap/${recap.stream.id}` };
const frame = { type: 'system', message: `📋 After-show report for "${recap.stream.title}" is in — grade ${g}: ${recap.write.headline}. Read it: /recap/${recap.stream.id}` };
if (delivery.ingress()) delivery.event({ kind: 'channel', id: recap.streamer.id }, frame, { key: `recap:${recap.stream.id}` });
if (delivery.ingress()) delivery.event({ kind: 'channel', id: recap.streamer.id }, frame, { key: `recap:${recap.stream.id}` });
- else {
- const chat = require('../chat/chat-server');
- const server = chat.chatServer || chat.default || chat;
- if (!server || typeof server.broadcastToChannelRoom !== 'function') return false;
- server.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, frame);
- }
+ else delivery.broadcastToChannelRoom(recap.streamer.id, recap.stream.id, frame);
db.run('UPDATE stream_recaps SET announced_at = CURRENT_TIMESTAMP WHERE stream_id = ?', [recap.stream.id]);
db.run('UPDATE stream_recaps SET announced_at = CURRENT_TIMESTAMP WHERE stream_id = ?', [recap.stream.id]);
return true;
return true;
} catch (e) { console.warn('[Recap] announce:', e.message); return false; }
} catch (e) { console.warn('[Recap] announce:', e.message); return false; }
@@ -21,7 +21,7 @@ const WebSocket = require('ws');
@@ -21,7 +21,7 @@ const WebSocket = require('ws');
const db = require('../db/database');
const db = require('../db/database');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const { extractWsToken, authenticateWs } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
-const chatServer = require('../chat/chat-server');
+const chatDelivery = require('../chat/chat-delivery');
const cosmetics = require('../monetization/cosmetics');
const cosmetics = require('../monetization/cosmetics');
const WS_HEARTBEAT_MS = 30000;
const WS_HEARTBEAT_MS = 30000;
@@ -239,7 +239,7 @@ class CallServer {
@@ -239,7 +239,7 @@ class CallServer {
const url = new URL(req.url, 'http://localhost');
const url = new URL(req.url, 'http://localhost');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
const channelId = url.searchParams.get('channelId') || url.searchParams.get('streamId');
const token = extractWsToken(req);
const token = extractWsToken(req);
- const ip = chatServer.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);
+ const ip = chatDelivery.normalizeIp(req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress);
ws.isAlive = true;
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
ws.on('pong', () => { ws.isAlive = true; });
@@ -263,7 +263,7 @@ class CallServer {
@@ -263,7 +263,7 @@ class CallServer {
if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
if (channel.private && !this._canSeePrivate(channel, user)) { ws.send(JSON.stringify({ type: 'error', message: 'This is a private call' })); ws.close(); return; }
const peerId = this._generatePeerId();
const peerId = this._generatePeerId();
- const anonId = user ? null : chatServer.getAnonIdForConnection(ip, resolvedId);
+ const anonId = user ? null : chatDelivery.getAnonIdForConnection(ip, resolvedId);
const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
const identity = user ? `u:${user.id}` : (anonId ? `a:${anonId}` : `ip:${ip}`);
const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
const cooled = this.kickCooldown.get(`${resolvedId}:${identity}`);
if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
if (cooled && cooled > Date.now()) { ws.send(JSON.stringify({ type: 'error', message: 'You were removed from this channel; try again in a minute' })); ws.close(); return; }
@@ -401,7 +401,7 @@ class CallServer {
@@ -401,7 +401,7 @@ class CallServer {
if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
if (c.ws.readyState === WebSocket.OPEN) { c.ws.send(JSON.stringify({ type: 'error', message: 'Banned' })); c.ws.close(); } break;
}
}
const ch = this.channels.get(channelId);
const ch = this.channels.get(channelId);
- c.user = user; c.anonId = user ? null : chatServer.getAnonIdForConnection(c.ip, channelId);
+ c.user = user; c.anonId = user ? null : chatDelivery.getAnonIdForConnection(c.ip, channelId);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isChannelCreator = !!(user && ch?.createdBy === user.id);
c.isStreamer = ch?.streamId ? !!(user && db.getStreamById(ch.streamId)?.user_id === user.id) : false;
c.isStreamer = ch?.streamId ? !!(user && db.getStreamById(ch.streamId)?.user_id === user.id) : false;
const pInfo = this._buildParticipantInfo(peerId, c);
const pInfo = this._buildParticipantInfo(peerId, c);
@@ -34,7 +34,6 @@ const openreMirror = require('../openre/mirror');
@@ -34,7 +34,6 @@ const openreMirror = require('../openre/mirror');
const i18n = require('../i18n/translate');
const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
-const chatServer = require('../chat/chat-server');
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
const moderation = require('../chat/moderation-client');
const moderation = require('../chat/moderation-client');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
@@ -114,7 +114,6 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -114,7 +114,6 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
const i = b.interaction || {};
const i = b.interaction || {};
const userId = channelUserId(b.creator && b.creator.id);
const userId = channelUserId(b.creator && b.creator.id);
if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });
if (!userId) return res.status(404).json({ error: 'this creator has no Live channel' });
- const chatServer = require('../chat/chat-server');
const streamId = streamIdFor(b.target, userId);
const streamId = streamIdFor(b.target, userId);
const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);
const name = String((b.supporter && b.supporter.name) || 'Someone').slice(0, 80);
// null = the supporter hid the amount (Tips privacy.hide_amount): it stays null all the way to the chat line.
// null = the supporter hid the amount (Tips privacy.hide_amount): it stays null all the way to the chat line.
@@ -137,12 +136,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -137,12 +136,12 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
// Chat persists the line and shows it in the channel and global chat; Tips' key is the operation's key.
chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
chatMessageId = await delivery.message({ ...line, mirror: true, key: `tips:${key}` });
} else {
} else {
- chatServer.broadcastToChannelRoom(userId, streamId, event);
- try { chatServer.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }
+ delivery.broadcastToChannelRoom(userId, streamId, event);
+ try { delivery.broadcastGlobal({ ...event, global: true, channel_user_id: userId }); } catch { /* non-critical */ }
const saved = db.saveChatMessage(line);
const saved = db.saveChatMessage(line);
chatMessageId = saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null;
chatMessageId = saved && saved.lastInsertRowid != null ? Number(saved.lastInsertRowid) : null;
}
}
- require('../monetization/alerts').playAlertSound(chatServer, userId, streamId, 'donation');
+ require('../monetization/alerts').playAlertSound(userId, streamId, 'donation');
return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));
return res.json(remember(key, { ok: true, ref: { chat_message_id: chatMessageId } }));
}
}
if (b.effect === 'tts') {
if (b.effect === 'tts') {
@@ -156,7 +155,7 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
@@ -156,7 +155,7 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
});
});
return res.json(remember(key, { ok: true, ref: { stream_id: streamId, chat_message_id: id } }));
return res.json(remember(key, { ok: true, ref: { stream_id: streamId, chat_message_id: id } }));
}
}
- await chatServer.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
+ await delivery.synthesizeAndBroadcastTTS(streamId, name, text, null, 'tips', `tips:${i.id}`, userId, `tips-${i.id}`);
return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));
return res.json(remember(key, { ok: true, ref: { stream_id: streamId } }));
}
}
if (b.effect === 'media_request') {
if (b.effect === 'media_request') {
@@ -16,8 +16,7 @@ function hasVibeCodingPublishScope(user) {
@@ -16,8 +16,7 @@ function hasVibeCodingPublishScope(user) {
}
}
class VibeCodingPublishServer {
class VibeCodingPublishServer {
- constructor(chatServer, db) {
- this.chatServer = chatServer;
+ constructor(db) {
this.db = db;
this.db = db;
this.wss = null;
this.wss = null;
this.clients = new Map();
this.clients = new Map();
@@ -46,7 +46,7 @@ function mountMetrics(app, { release }) {
@@ -46,7 +46,7 @@ function mountMetrics(app, { release }) {
/**
/**
* Domain gauges, read at scrape time. A source that throws is left out of that scrape.
* Domain gauges, read at scrape time. A source that throws is left out of that scrape.
* liveStreams() -> number of live streams (the rows /api/streams lists)
* liveStreams() -> number of live streams (the rows /api/streams lists)
- * wsServers -> { chat, broadcast, control, call } objects with a `wss` (ws.Server) once initialised
+ * wsServers -> { broadcast, control, call } objects with a `wss` (ws.Server) once initialised (chat is OpenVibe.Chat's)
* outboxStatus() -> server/events/stream-events.js status()
* outboxStatus() -> server/events/stream-events.js status()
*/
*/
function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {
function registerDomainGauges(registry, { liveStreams, wsServers = {}, outboxStatus }) {
@@ -1,31 +1,38 @@
@@ -1,31 +1,38 @@
'use strict';
'use strict';
-// server/monetization/alerts.js after roadmap T3: Live no longer reads the alert sound from disk or
-// broadcasts base64 — the sound mapping lives on channel_moderation_settings, which OpenVibe.Chat
-// owns — it asks Chat through the bridge op `playAlertSound [streamerId, streamId, kind]`. This
-// checks the dispatcher; the op itself (resolving the sound, reading the shared file, broadcasting
-// `soundboard-audio`) lives in Chat and is tested there.
+// server/monetization/alerts.js after roadmap T3: the alert-sound mapping lives on
+// channel_moderation_settings, which OpenVibe.Chat owns, so Live asks Chat to play the sound with an
+// `alert` event on its typed ingress (server/chat/chat-delivery.js). This checks the event shape; the
+// op itself (resolving the sound, reading the shared file, broadcasting `soundboard-audio`) lives in
+// Chat and is tested there.
//
//
// node test/alerts-bridge.test.js
// node test/alerts-bridge.test.js
const assert = require('assert');
const assert = require('assert');
+const delivery = require('../server/chat/chat-delivery');
const alerts = require('../server/monetization/alerts');
const alerts = require('../server/monetization/alerts');
const calls = [];
const calls = [];
-const fake = { playAlertSound: (...args) => calls.push(args) };
+const realEvent = delivery.event;
+delivery.event = (target, frame) => { calls.push({ target, frame }); return Promise.resolve({ ok: true }); };
+try {
+ alerts.playAlertSound(7, 3, 'donation');
+ alerts.playAlertSound(7, 3, 'goal');
+ alerts.playAlertSound(7, 3, 'anything-else');
+ // Offline room: a null stream id is passed straight through (Chat reaches the channel room).
+ alerts.playAlertSound(7, null, 'goal');
+} finally {
+ delivery.event = realEvent;
+}
+assert.deepStrictEqual(calls.map(({ target, frame }) => [target.kind, target.id, frame.streamerId, frame.streamId, frame.kind]), [
+ ['channel', 7, 7, 3, 'donation'],
+ ['channel', 7, 7, 3, 'goal'],
+ ['channel', 7, 7, 3, 'donation'],
+ ['channel', 7, 7, null, 'goal'],
+], 'unknown kinds fall back to the donation sound, and the alert targets the channel owner');
-alerts.playAlertSound(fake, 7, 3, 'donation');
-alerts.playAlertSound(fake, 7, 3, 'goal');
-alerts.playAlertSound(fake, 7, 3, 'anything-else');
-assert.deepStrictEqual(calls, [[7, 3, 'donation'], [7, 3, 'goal'], [7, 3, 'donation']], 'unknown kinds fall back to the donation sound');
+// Outside chat mode the seam drops the event (logged once), never throws into the caller.
+delete process.env.CHAT_AUTHORITY;
+assert.doesNotThrow(() => alerts.playAlertSound(7, 3, 'donation'));
-// Offline room: a null stream id is passed straight through (Chat reaches the channel room).
-alerts.playAlertSound(fake, 7, null, 'goal');
-assert.deepStrictEqual(calls[3], [7, null, 'goal']);
-
-// Live runs chat itself (rollback / dev): no Chat to ask — silent no-op, never throws.
-alerts.playAlertSound({}, 7, 3, 'donation');
-alerts.playAlertSound(null, 7, 3, 'donation');
-assert.strictEqual(calls.length, 4, 'a chat server without the op is not called');
-
-console.log('alerts bridge: all checks passed');
+console.log('alerts event: all checks passed');
@@ -82,10 +82,9 @@ const wrap = (d) => ({
@@ -82,10 +82,9 @@ const wrap = (d) => ({
JOIN users u ON shared.user_id = u.id WHERE mine.user_id = ? GROUP BY shared.user_id`).all(uid, uid),
JOIN users u ON shared.user_id = u.id WHERE mine.user_id = ? GROUP BY shared.user_id`).all(uid, uid),
logModerationAction: () => {},
logModerationAction: () => {},
};
};
- const chatServer = { getConnectedUserIp: () => '203.0.113.7', disconnectUser: () => {} };
- const delivery = { disconnect: () => {}, logModeration: () => {} };
+ const delivery = { getConnectedUserIp: () => '203.0.113.7', disconnect: () => {}, logModeration: () => {} };
// eslint-disable-next-line no-new-func
// eslint-disable-next-line no-new-func
- const fn = new Function('db', 'chatServer', 'delivery', 'permissions', 'console', `${m[0]}; return performGlobalBan;`)(db, chatServer, delivery, permissions, { log() {} });
+ const fn = new Function('db', 'delivery', 'permissions', 'console', `${m[0]}; return performGlobalBan;`)(db, delivery, permissions, { log() {} });
let status = 200, body = null;
let status = 200, body = null;
const res = { status(c) { status = c; return this; }, json(b) { body = b; return this; } };
const res = { status(c) { status = c; return this; }, json(b) { body = b; return this; } };
const actor = db.getUserById(actorId);
const actor = db.getUserById(actorId);
deleted file mode 100644
deleted file mode 100644
@@ -1,67 +0,0 @@
@@ -1,67 +0,0 @@
-/**
- * Chat WebSocket per-address cap: one address cannot hold more than MAX_CHAT_SOCKETS_PER_IP
- * sockets, closing one frees a slot, and the count does not leak.
- *
- * node test/chat-connection-cap.test.js
- */
-'use strict';
-const assert = require('assert');
-const fs = require('fs');
-const os = require('os');
-const path = require('path');
-const http = require('http');
-
-const tmp = path.join(os.tmpdir(), `ov-chatcap-${process.pid}.db`);
-process.env.DB_PATH = tmp;
-process.env.NODE_ENV = 'test';
-const quiet = console.log;
-console.log = () => {};
-console.warn = () => {};
-
-const db = require('../server/db/database');
-db.initDb();
-const WebSocket = require('ws');
-const chat = require('../server/chat/chat-server');
-
-const server = http.createServer();
-chat.init(server);
-server.on('upgrade', (req, socket, head) => { if (!chat.handleUpgrade(req, socket, head)) socket.destroy(); });
-
-const open = (port, ip) => new Promise((resolve) => {
- const ws = new WebSocket(`ws://127.0.0.1:${port}/ws/chat`, { headers: { 'cf-connecting-ip': ip } });
- ws.on('open', () => {
- // The server closes over-cap sockets right after the upgrade; give it a tick to say so.
- const t = setTimeout(() => resolve({ ws, code: null }), 150);
- ws.on('close', (code) => { clearTimeout(t); resolve({ ws, code }); });
- });
- ws.on('error', () => resolve({ ws, code: -1 }));
-});
-
-server.listen(0, async () => {
- const port = server.address().port;
- let exit = 0;
- try {
- const held = [];
- for (let i = 0; i < 48; i++) {
- const r = await open(port, '203.0.113.7');
- assert.strictEqual(r.code, null, `socket ${i + 1} should stay open`);
- held.push(r.ws);
- }
- assert.strictEqual((await open(port, '203.0.113.7')).code, 4029, '49th socket from one address is refused');
- assert.strictEqual((await open(port, '198.51.100.9')).code, null, 'another address is unaffected');
- held.pop().close();
- await new Promise((r) => setTimeout(r, 150));
- const again = await open(port, '203.0.113.7');
- assert.strictEqual(again.code, null, 'closing a socket frees a slot');
- held.push(again.ws);
- held.forEach((w) => w.close());
- await new Promise((r) => setTimeout(r, 300));
- assert.strictEqual(chat._ipSockets.get('203.0.113.7') || 0, 0, 'count returns to zero');
- quiet('chat connection cap: all checks passed');
- } catch (e) {
- quiet('✗', e.message);
- exit = 1;
- }
- for (const ext of ['', '-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
- process.exit(exit);
-});
@@ -298,7 +298,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -298,7 +298,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);
assert.strictEqual((await call('POST', '/internal/chat-effects/user-color', { token: WRITE, body: { user_id: viewer, color: '#ff00ff' } })).status, 200);
assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');
assert.strictEqual(db.getUserById(viewer).profile_color, '#ff00ff');
- // 6. Bans: Live re-checks the moderator; the rows are exactly chat-server's.
+ // 6. Bans: Live re-checks the moderator; the rows are exactly Live's own bans table's.
const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });
const ban = (body) => call('POST', '/internal/chat-effects/ban', { token: WRITE, body });
assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');
assert.strictEqual((await ban({ action: 'ban', actor_user_id: viewer, moderation_stream_id: streamId, stream_id: streamId, user_id: mod })).status, 403, 'a viewer cannot ban');
assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');
assert.strictEqual((await ban({ action: 'ban', actor_user_id: mod, moderation_stream_id: streamId, stream_id: streamId, user_id: owner })).status, 403, 'a channel mod cannot ban an admin');
@@ -383,10 +383,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -383,10 +383,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
assert.strictEqual(db.getChatMessageById(900001), undefined);
assert.strictEqual(db.getChatMessageById(900001), undefined);
assert.ok(db.getUserById(viewer), 'users are never touched');
assert.ok(db.getUserById(viewer), 'users are never touched');
- // 10. The chat server Live's modules get (T3 J2: the bridge and its outbox are gone).
- const chatServer = require('../server/chat/chat-server');
- assert.strictEqual(chatServer.remote, true);
- chatServer.init();
+ // 10. The chat seam Live's modules get (T3 J2: Live runs no chat server — the bridge, its
+ // outbox and the local ChatServer are gone; chat-delivery carries the push/presence surface).
+ const chatDelivery = require('../server/chat/chat-delivery');
+ chatDelivery.init();
assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get(), 'no outbox');
assert.ok(!d.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_bridge_outbox'").get(), 'no outbox');
// Live's own writes to data Chat caches (IP approvals, bans) send Chat a cache hint.
// Live's own writes to data Chat caches (IP approvals, bans) send Chat a cache hint.
db.approveIp(channel.id, '203.0.113.7', streamer, 'manual');
db.approveIp(channel.id, '203.0.113.7', streamer, 'manual');
@@ -394,27 +394,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -394,27 +394,25 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
for (let i = 0; i < 100 && ingressCalls.filter((c) => c.family === 'invalidate').length < 2; i++) await sleep(20);
for (let i = 0; i < 100 && ingressCalls.filter((c) => c.family === 'invalidate').length < 2; i++) await sleep(20);
assert.deepStrictEqual(ingressCalls.filter((c) => c.family === 'invalidate').map((c) => (c.body.bans ? 'bans' : c.body.approvals)), [channel.id, 'bans']);
assert.deepStrictEqual(ingressCalls.filter((c) => c.family === 'invalidate').map((c) => (c.body.bans ? 'bans' : c.body.approvals)), [channel.id, 'bans']);
// Synchronous reads come from Chat's presence snapshot.
// Synchronous reads come from Chat's presence snapshot.
- for (let i = 0; i < 100 && chatServer.getTotalConnections() !== 7; i++) await sleep(20);
- assert.strictEqual(chatServer.getTotalConnections(), 7);
- assert.strictEqual(chatServer.getStreamViewerCount(1), 3);
- assert.strictEqual(chatServer.slowModeByStream.get(1), 5000);
- assert.strictEqual(chatServer.getConnectedUserIp(3), '198.51.100.3');
- assert.strictEqual(chatServer.findClientByAnonId('anon9', 1).ip, '203.0.113.9');
- assert.strictEqual(chatServer.findClientByAnonId('anon9', 2), null);
- assert.strictEqual(chatServer.getAnonIdForConnection('203.0.113.9', 1), 'anon9');
+ for (let i = 0; i < 100 && chatDelivery.getTotalConnections() !== 7; i++) await sleep(20);
+ assert.strictEqual(chatDelivery.getTotalConnections(), 7);
+ assert.strictEqual(chatDelivery.getStreamViewerCount(1), 3);
+ assert.strictEqual(chatDelivery.slowModeByStream.get(1), 5000);
+ assert.strictEqual(chatDelivery.getConnectedUserIp(3), '198.51.100.3');
+ assert.strictEqual(chatDelivery.findClientByAnonId('anon9', 1).ip, '203.0.113.9');
+ assert.strictEqual(chatDelivery.findClientByAnonId('anon9', 2), null);
+ assert.strictEqual(chatDelivery.findClientByAnonId('anon9').ip, '203.0.113.9', 'no stream given: any of that anon\'s sockets');
+ assert.strictEqual(chatDelivery.getAnonIdForConnection('203.0.113.9', 1), 'anon9');
// Chat asks Live for anon numbers; Live answers from its own helpers.
// Chat asks Live for anon numbers; Live answers from its own helpers.
- assert.strictEqual(typeof (await chatServer.resolveAnon('203.0.113.9')).anon_number, 'number');
+ assert.strictEqual(typeof (await chatDelivery.resolveAnon('203.0.113.9')).anon_number, 'number');
// !arena answers the sender in the response, after its own lookup (review 2026-10-02, PR #12).
// !arena answers the sender in the response, after its own lookup (review 2026-10-02, PR #12).
const arenaCmd = await call('POST', '/internal/chat-effects/arena-command', { token: WRITE, body: { cmd: '!arena', parts: ['!arena', 'nobody_here_at_all'], client: { conn_id: 'c1', streamId, ip: '203.0.113.30' } } });
const arenaCmd = await call('POST', '/internal/chat-effects/arena-command', { token: WRITE, body: { cmd: '!arena', parts: ['!arena', 'nobody_here_at_all'], client: { conn_id: 'c1', streamId, ip: '203.0.113.30' } } });
assert.strictEqual(arenaCmd.status, 200);
assert.strictEqual(arenaCmd.status, 200);
assert.strictEqual(arenaCmd.body.handled, true);
assert.strictEqual(arenaCmd.body.handled, true);
assert.strictEqual(arenaCmd.body.replies.length, 1, 'the reply made after the lookup is in the answer');
assert.strictEqual(arenaCmd.body.replies.length, 1, 'the reply made after the lookup is in the answer');
assert.strictEqual(arenaCmd.body.replies[0].type, 'system');
assert.strictEqual(arenaCmd.body.replies[0].type, 'system');
- // A /ws/chat upgrade that still lands on Live is refused, not served from the mirror.
- let written = '';
- chatServer.handleUpgrade({}, { write: (x) => { written += x; }, destroy() {} });
- assert.match(written, /^HTTP\/1\.1 503/);
- chatServer.close();
+ // /ws/chat is OpenVibe.Chat's (nginx routes it to 127.0.0.1:4400); Live mounts no chat server.
+ chatDelivery.close();
// 11. Live's chat reads (roadmap T3 J4b): stats, queues and history come from Chat's read
// 11. Live's chat reads (roadmap T3 J4b): stats, queues and history come from Chat's read
// API through server/chat/chat-reads.js, with the caching and failure rules the moderation
// API through server/chat/chat-reads.js, with the caching and failure rules the moderation
@@ -117,10 +117,8 @@ function assertSigned(c, what) {
@@ -117,10 +117,8 @@ function assertSigned(c, what) {
const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
const streamId = Number(db.createStream({ user_id: streamer, channel_id: channel.id, title: 'Live now' }).lastInsertRowid);
const delivery = require('../server/chat/chat-delivery');
const delivery = require('../server/chat/chat-delivery');
- const chatServer = require('../server/chat/chat-server');
- assert.strictEqual(chatServer.remote, true);
assert.strictEqual(delivery.ingress(), true, 'CHAT_AUTHORITY=chat is ingress, with no flag');
assert.strictEqual(delivery.ingress(), true, 'CHAT_AUTHORITY=chat is ingress, with no flag');
- chatServer.init();
+ delivery.init();
delivery.client._setRetryMs([20, 20]);
delivery.client._setRetryMs([20, 20]);
let exit = 0;
let exit = 0;
@@ -206,7 +204,7 @@ function assertSigned(c, what) {
@@ -206,7 +204,7 @@ function assertSigned(c, what) {
// 5b. A news headline: one keyed /events card to the stream.
// 5b. A news headline: one keyed /events card to the stream.
const news = require('../server/news/news-service');
const news = require('../server/news/news-service');
- news._chatServer = chatServer;
+ news._chatServer = delivery;
news._getActiveStreamIds = () => [streamId];
news._getActiveStreamIds = () => [streamId];
news.isEnabledForStream = () => true;
news.isEnabledForStream = () => true;
news._pendingQueue.push({ headline: 'Big news', sourceId: 'test' });
news._pendingQueue.push({ headline: 'Big news', sourceId: 'test' });
@@ -218,7 +216,7 @@ function assertSigned(c, what) {
@@ -218,7 +216,7 @@ function assertSigned(c, what) {
// 6. Deploy notice: no ingress endpoint, Events off → left unannounced (next boot retries), never bridged.
// 6. Deploy notice: no ingress endpoint, Events off → left unannounced (next boot retries), never bridged.
const before = db.getSetting('deploy_last_announced');
const before = db.getSetting('deploy_last_announced');
- const dn = await require('../server/chat/deploy-notice').announce({ db, chatServer, log: { warn() {}, log() {}, info() {} } });
+ const dn = await require('../server/chat/deploy-notice').announce({ db, log: { warn() {}, log() {}, info() {} } });
assert.strictEqual(dn.announced, 0);
assert.strictEqual(dn.announced, 0);
assert.strictEqual(db.getSetting('deploy_last_announced'), before, 'not recorded as announced');
assert.strictEqual(db.getSetting('deploy_last_announced'), before, 'not recorded as announced');
@@ -229,8 +227,8 @@ function assertSigned(c, what) {
@@ -229,8 +227,8 @@ function assertSigned(c, what) {
assertSigned(await waitFor(() => find('invalidate', (b) => b.user === viewer), 'user hint'), 'user hint');
assertSigned(await waitFor(() => find('invalidate', (b) => b.user === viewer), 'user hint'), 'user hint');
// 8. Presence comes from /internal/chat/presence.
// 8. Presence comes from /internal/chat/presence.
- await waitFor(() => chatServer.getTotalConnections() === 4, 'presence snapshot');
- assert.strictEqual(chatServer.getConnectedUserIp(99), '198.51.100.9');
+ await waitFor(() => delivery.getTotalConnections() === 4, 'presence snapshot');
+ assert.strictEqual(delivery.getConnectedUserIp(99), '198.51.100.9');
// 9. A Chat 5xx: retried with the same key, then given up; never thrown, never bridged.
// 9. A Chat 5xx: retried with the same key, then given up; never thrown, never bridged.
fail.events = { status: 503, times: 1 };
fail.events = { status: 503, times: 1 };
@@ -264,12 +262,12 @@ function assertSigned(c, what) {
@@ -264,12 +262,12 @@ function assertSigned(c, what) {
const saved = {};
const saved = {};
for (const k of synth) { saved[k] = tts[k]; tts[k] = (...a) => { touched.push(k); return saved[k](...a); }; }
for (const k of synth) { saved[k] = tts[k]; tts[k] = (...a) => { touched.push(k); return saved[k](...a); }; }
const n = calls.length;
const n = calls.length;
- chatServer.broadcastToStream(streamId, { type: 'system', message: 'direct' });
- chatServer.sendDm(viewer, { type: 'dm-notice' });
- chatServer.disconnectUser({ userId: viewer, streamId });
- chatServer.sendUserUpdate(viewer, { username: 'viewer', display_name: 'VIEWER', password_hash: 'never' });
- chatServer.forwardToGlobal(streamId, { type: 'chat', message: 'no target' });
- await chatServer.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, 'm1');
+ delivery.broadcastToStream(streamId, { type: 'system', message: 'direct' });
+ delivery.sendDm(viewer, { type: 'dm-notice' });
+ delivery.disconnectUser({ userId: viewer, streamId });
+ delivery.sendUserUpdate(viewer, { username: 'viewer', display_name: 'VIEWER', password_hash: 'never' });
+ delivery.forwardToGlobal(streamId, { type: 'chat', message: 'no target' });
+ await delivery.synthesizeAndBroadcastTTS(streamId, 'Bot', 'beep', null, null, 'aibot:bot', null, 'm1');
for (const k of synth) tts[k] = saved[k];
for (const k of synth) tts[k] = saved[k];
assert.deepStrictEqual(touched, [], 'no TTS synthesis in Live');
assert.deepStrictEqual(touched, [], 'no TTS synthesis in Live');
await waitFor(() => calls.length >= n + 4, 'direct pushes');
await waitFor(() => calls.length >= n + 4, 'direct pushes');
@@ -279,9 +277,9 @@ function assertSigned(c, what) {
@@ -279,9 +277,9 @@ function assertSigned(c, what) {
// CHAT_AUTHORITY unset under a RemoteChatServer (a test or a bad env edit): dropped, never bounced back.
// CHAT_AUTHORITY unset under a RemoteChatServer (a test or a bad env edit): dropped, never bounced back.
process.env.CHAT_AUTHORITY = '';
process.env.CHAT_AUTHORITY = '';
assert.strictEqual(delivery.ingress(), false);
assert.strictEqual(delivery.ingress(), false);
- assert.strictEqual(chatServer.broadcastToStream(streamId, { type: 'system', message: 'x' }), undefined);
+ assert.strictEqual(delivery.broadcastToStream(streamId, { type: 'system', message: 'x' }), undefined);
process.env.CHAT_AUTHORITY = 'chat';
process.env.CHAT_AUTHORITY = 'chat';
- chatServer.close();
+ delivery.close();
quiet('chat ingress (CHAT_AUTHORITY=chat): all checks passed');
quiet('chat ingress (CHAT_AUTHORITY=chat): all checks passed');
} catch (err) {
} catch (err) {
@@ -1,3 +1,8 @@
@@ -1,3 +1,8 @@
+'use strict';
+// Live no longer runs a chat server: DM delivery is OpenVibe.Chat's (chat-delivery.js only carries
+// the seam). server/chat/dm.js stays for its tables and the participant/block helpers Live's own
+// routes still use — server/index.js ensureTables() and server/streaming/routes.js (call blocking by
+// isBlockedEither). This checks those helpers.
const assert = require('assert');
const assert = require('assert');
const os = require('os');
const os = require('os');
const path = require('path');
const path = require('path');
@@ -8,7 +13,6 @@ process.env.DB_PATH = tempDbPath;
@@ -8,7 +13,6 @@ process.env.DB_PATH = tempDbPath;
const db = require('../server/db/database');
const db = require('../server/db/database');
const dm = require('../server/chat/dm');
const dm = require('../server/chat/dm');
-const chatServer = require('../server/chat/chat-server');
async function run() {
async function run() {
try {
try {
@@ -28,36 +32,16 @@ async function run() {
@@ -28,36 +32,16 @@ async function run() {
assert(dm.isParticipant(convId, userC), 'userC should be participant');
assert(dm.isParticipant(convId, userC), 'userC should be participant');
assert(!dm.isParticipant(convId, userD), 'userD should not be a participant');
assert(!dm.isParticipant(convId, userD), 'userD should not be a participant');
- const sent = [];
- const makeWs = (label, userId) => ({
- readyState: 1,
- bufferedAmount: 0,
- send(payload) {
- sent.push({ label, payload, userId });
- },
- });
+ // The call routes use these to refuse a ring between users either of whom blocked the other.
+ assert.strictEqual(dm.isBlockedEither(userA, userD), false);
+ dm.blockUser(userD, userA);
+ assert(dm.isBlockedEither(userA, userD), 'a block in either direction counts');
+ assert(dm.hasBlocked(userD, userA));
+ assert(!dm.hasBlocked(userA, userD), 'blocking stays one-directional in the row');
+ dm.unblockUser(userD, userA);
+ assert.strictEqual(dm.isBlockedEither(userA, userD), false);
- const wsA1 = makeWs('A1', userA);
- const wsA2 = makeWs('A2', userA);
- const wsB = makeWs('B', userB);
- const wsD = makeWs('D', userD);
-
- chatServer.clients.set(wsA1, { user: { id: userA, username: 'accountA' } });
- chatServer.clients.set(wsA2, { user: { id: userA, username: 'accountA' } });
- chatServer.clients.set(wsB, { user: { id: userB, username: 'accountB' } });
- chatServer.clients.set(wsD, { user: { id: userD, username: 'accountD' } });
-
- chatServer.sendDm(userA, { type: 'dm', conversation_id: convId, message: { id: 1, text: 'hello A' } });
- assert.strictEqual(sent.filter(x => x.label.startsWith('A')).length, 2, 'userA sockets should receive DM');
-
- chatServer.sendDm(userB, { type: 'dm', conversation_id: convId, message: { id: 2, text: 'hello B' } });
- assert.strictEqual(sent.filter(x => x.label === 'B').length, 1, 'userB should receive DM');
-
- const beforeCount = sent.length;
- chatServer.sendDm(userD, { type: 'dm', conversation_id: convId, message: { id: 3, text: 'should not deliver' } });
- assert.strictEqual(sent.length, beforeCount, 'non-participant should not receive DM');
-
- console.log('✅ DM delivery regression test passed');
+ console.log('✅ DM helpers (participants, blocks) regression test passed');
} finally {
} finally {
try { db.close(); } catch {};
try { db.close(); } catch {};
try { fs.unlinkSync(tempDbPath); } catch {};
try { fs.unlinkSync(tempDbPath); } catch {};
@@ -65,6 +49,6 @@ async function run() {
@@ -65,6 +49,6 @@ async function run() {
}
}
run().catch((err) => {
run().catch((err) => {
- console.error('DM delivery test failed:', err);
+ console.error('DM helpers test failed:', err);
process.exit(1);
process.exit(1);
});
});
@@ -23,7 +23,6 @@ function stub(modPath, exportsObj) {
@@ -23,7 +23,6 @@ function stub(modPath, exportsObj) {
stub(path.join(__dirname, '../server/db/database'), {});
stub(path.join(__dirname, '../server/db/database'), {});
stub(path.join(__dirname, '../server/auth/auth'), { authenticateWs: () => null, extractWsToken: () => null });
stub(path.join(__dirname, '../server/auth/auth'), { authenticateWs: () => null, extractWsToken: () => null });
-stub(path.join(__dirname, '../server/chat/chat-server'), { _broadcastMessage: () => {} });
const controlServer = require('../server/controls/control-server');
const controlServer = require('../server/controls/control-server');
const chatRelay = require('../server/integrations/chat-relay-service');
const chatRelay = require('../server/integrations/chat-relay-service');
@@ -58,7 +58,7 @@ const stub = http.createServer((req, res) => {
@@ -58,7 +58,7 @@ const stub = http.createServer((req, res) => {
assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);
assert.throws(() => releaseEvents.envelopeFor({ head: 'nope' }), /full commit sha/);
// Events off: the notice works exactly as before and nothing is queued.
// Events off: the notice works exactly as before and nothing is queued.
- let r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });
+ let r = await dn.announce({ db, log: quiet });
assert.ok(r.announced >= 1);
assert.ok(r.announced >= 1);
assert.strictEqual(r.event_id, null);
assert.strictEqual(r.event_id, null);
assert.strictEqual(db.getSetting(dn.SETTING), head);
assert.strictEqual(db.getSetting(dn.SETTING), head);
@@ -68,7 +68,7 @@ const stub = http.createServer((req, res) => {
@@ -68,7 +68,7 @@ const stub = http.createServer((req, res) => {
const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
const outbox = streamEvents.init({ eventsUrl: base, clientSecret: 's3cret', intervalMs: 50 });
assert.ok(outbox);
assert.ok(outbox);
db.setSetting(dn.SETTING, '');
db.setSetting(dn.SETTING, '');
- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });
+ r = await dn.announce({ db, log: quiet });
assert.ok(r.announced >= 1);
assert.ok(r.announced >= 1);
assert.match(r.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.match(r.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
let rows = outboxRows();
let rows = outboxRows();
@@ -86,7 +86,7 @@ const stub = http.createServer((req, res) => {
@@ -86,7 +86,7 @@ const stub = http.createServer((req, res) => {
assert.strictEqual(sent.event_type, 'live.release.deployed');
assert.strictEqual(sent.event_type, 'live.release.deployed');
// A restart with no new code: no notice, no event.
// A restart with no new code: no notice, no event.
- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });
+ r = await dn.announce({ db, log: quiet });
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
assert.strictEqual(outboxRows().length, 1);
assert.strictEqual(outboxRows().length, 1);
@@ -95,7 +95,7 @@ const stub = http.createServer((req, res) => {
@@ -95,7 +95,7 @@ const stub = http.createServer((req, res) => {
db.setSetting(dn.SETTING, '');
db.setSetting(dn.SETTING, '');
const chatBefore = deployRows().map(x => x.metadata).join('|');
const chatBefore = deployRows().map(x => x.metadata).join('|');
raw.exec("CREATE TEMP TRIGGER outbox_boom BEFORE INSERT ON event_outbox BEGIN SELECT RAISE(ABORT, 'outbox insert failed'); END");
raw.exec("CREATE TEMP TRIGGER outbox_boom BEFORE INSERT ON event_outbox BEGIN SELECT RAISE(ABORT, 'outbox insert failed'); END");
- r = await dn.announce({ db, chatServer: { remote: false, clients: new Map() }, log: quiet });
+ r = await dn.announce({ db, log: quiet });
raw.exec('DROP TRIGGER temp.outbox_boom');
raw.exec('DROP TRIGGER temp.outbox_boom');
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
assert.strictEqual(db.getSetting(dn.SETTING), '');
assert.strictEqual(db.getSetting(dn.SETTING), '');
@@ -104,7 +104,7 @@ const stub = http.createServer((req, res) => {
@@ -104,7 +104,7 @@ const stub = http.createServer((req, res) => {
// CHAT_AUTHORITY=chat with Events on: Chat learns the deploy from the event alone (C-84).
// CHAT_AUTHORITY=chat with Events on: Chat learns the deploy from the event alone (C-84).
process.env.CHAT_AUTHORITY = 'chat';
process.env.CHAT_AUTHORITY = 'chat';
- r = await dn.announce({ db, chatServer: { remote: true }, log: quiet });
+ r = await dn.announce({ db, log: quiet });
assert.ok(r.announced >= 1);
assert.ok(r.announced >= 1);
assert.strictEqual(db.getSetting(dn.SETTING), head);
assert.strictEqual(db.getSetting(dn.SETTING), head);
rows = outboxRows();
rows = outboxRows();
@@ -116,7 +116,7 @@ const stub = http.createServer((req, res) => {
@@ -116,7 +116,7 @@ const stub = http.createServer((req, res) => {
streamEvents._reset();
streamEvents._reset();
process.env.EVENTS_PUBLISH = 'off';
process.env.EVENTS_PUBLISH = 'off';
db.setSetting(dn.SETTING, '');
db.setSetting(dn.SETTING, '');
- r = await dn.announce({ db, chatServer: { remote: true }, log: quiet });
+ r = await dn.announce({ db, log: quiet });
delete process.env.EVENTS_PUBLISH;
delete process.env.EVENTS_PUBLISH;
delete process.env.CHAT_AUTHORITY;
delete process.env.CHAT_AUTHORITY;
assert.strictEqual(r.announced, 0);
assert.strictEqual(r.announced, 0);
@@ -231,21 +231,6 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
@@ -231,21 +231,6 @@ const ok = (name) => { pass++; console.log(' ok -', name); };
ok('media quote refuses internal addresses (all IPv6 spellings) and non-http schemes');
ok('media quote refuses internal addresses (all IPv6 spellings) and non-http schemes');
}
}
-// ── A ban has to stop commands too, not just plain messages ──────────────────
-{
- const src = fs.readFileSync(path.join(ROOT, 'server/chat/chat-server.js'), 'utf8');
- const fnStart = src.indexOf('handleChatMessage(ws, client, msg) {');
- assert(fnStart > 0, 'handleChatMessage should exist');
- const body = src.slice(fnStart, fnStart + 3000);
- const banAt = body.indexOf('db.isUserBanned(client.user.id, modStreamId)');
- const bangAt = body.indexOf("text.startsWith('!')");
- const slashAt = body.indexOf("text.startsWith('/')");
- assert(banAt > 0 && bangAt > 0 && slashAt > 0, 'all three branches should be present');
- assert(banAt < bangAt && banAt < slashAt,
- 'the ban check must run before the "!" and "/" command dispatch, which both return early');
- ok('banned users cannot run ! or / chat commands');
-}
-
// ── A private VOD's context must be authorised before the cache is consulted ──
// ── A private VOD's context must be authorised before the cache is consulted ──
{
{
const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');
const src = fs.readFileSync(path.join(ROOT, 'server/media-proxy/vods.js'), 'utf8');
@@ -349,7 +349,7 @@ async function check(name, fn) {
@@ -349,7 +349,7 @@ async function check(name, fn) {
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/chat/moderation-client.js': 'OpenVibe.Chat internal read API (configured)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/insight-client.js': 'OpenVibe.Chat public chat-AI reads (configured OV_CHAT_INTERNAL_URL, fixed paths, ids validated)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
'server/chat/chat-client.js': 'OpenVibe.Chat typed ingress (configured OV_CHAT_INTERNAL_URL, fixed /internal/chat/* paths)',
- 'server/chat/chat-server.js': 'OpenVibe.Tools anon resolve (configured)',
+ 'server/chat/chat-delivery.js': 'OpenVibe.Tools anon resolve (configured)',
'server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)',
'server/streaming/calls-authority.js': 'OpenVibe.Chat (configured)',
'server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',
'server/streaming/golive-notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/utils/notify.js': 'OpenVibe.Network (configured)',
@@ -62,7 +62,6 @@ assert.deepStrictEqual(p.getCapabilities(null).staff_caps, []);
@@ -62,7 +62,6 @@ assert.deepStrictEqual(p.getCapabilities(null).staff_caps, []);
const TARGET_RANK = [
const TARGET_RANK = [
['internal/routes.js', "user.is_owner && role !== 'admin'"],
['internal/routes.js', "user.is_owner && role !== 'admin'"],
['chat/live-context-routes.js', "target.role === 'admin'"],
['chat/live-context-routes.js', "target.role === 'admin'"],
- ['chat/chat-server.js', "targetUser.role === 'admin'"],
['admin/routes.js', "target.role === 'admin'"],
['admin/routes.js', "target.role === 'admin'"],
['admin/routes.js', "banTarget.role === 'admin'"],
['admin/routes.js', "banTarget.role === 'admin'"],
['admin/routes.js', "if (user.role === 'admin') return res.status(400)"],
['admin/routes.js', "if (user.role === 'admin') return res.status(400)"],
@@ -38,12 +38,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
@@ -38,12 +38,12 @@ const token = (cap, aud = 'openvibe.live') => serviceAuth.signServiceToken({ iss
d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
d.prepare("INSERT INTO users (id, username, display_name, password_hash, openvibe_bucks_balance, openvibe_bucks_cashout_balance) VALUES (501, 'alex', 'Alex', 'x', 0, 0)").run();
d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (501, 'network', '77', ?)").run(SUBJECT);
- const chatServer = require('../server/chat/chat-server');
+ const delivery = require('../server/chat/chat-delivery');
const broadcasts = [];
const broadcasts = [];
- chatServer.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
- chatServer.broadcastGlobal = () => {};
+ delivery.broadcastToChannelRoom = (uid, sid, ev) => broadcasts.push({ uid, sid, ev });
+ delivery.broadcastGlobal = () => {};
const tts = [];
const tts = [];
- chatServer.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
+ delivery.synthesizeAndBroadcastTTS = async (...a) => { tts.push(a); };
const app = express();
const app = express();
app.use('/internal/tips', require('../server/tips/delivery-routes'));
app.use('/internal/tips', require('../server/tips/delivery-routes'));
@@ -54,7 +54,7 @@ function connect(port, token) {
@@ -54,7 +54,7 @@ function connect(port, token) {
assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');
assert.strictEqual(hasVibeCodingPublishScope({ scopes: ['read', 'stream', 'control'] }), false, 'the broad stream scope is not a publisher scope');
// The real WebSocket endpoint with real hbt_ tokens.
// The real WebSocket endpoint with real hbt_ tokens.
- const pub = new VibeCodingPublishServer({ broadcastToStream() {} }, db);
+ const pub = new VibeCodingPublishServer(db);
const server = http.createServer();
const server = http.createServer();
pub.init(server);
pub.init(server);
server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });
server.on('upgrade', (req, socket, head) => { if (!pub.handleUpgrade(req, socket, head)) socket.destroy(); });