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)

OP OpenVibers committed 5 Oct 2026 · 9f758487b0 · 43 files, +447 −3022

* 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)

## Changes (file:line)

**Core**
- `server/chat/chat-server.js` — **deleted** (2648 lines: local `ChatServer` + `RemoteChatServer`).
- `server/chat/chat-delivery.js:1-34` — header rewritten: Live runs no chat server in any mode; this is the only seam.
- `server/chat/chat-delivery.js:63-76` — `dropped()` + gated `event()`; `disconnect()` at :91, `logModeration()`/`mirror()` now use the module-level `db` (:83,:111).
- `server/chat/chat-delivery.js:122-138` — ChatServer push names kept: `broadcastToStream`/`broadcastToChannelRoom`/`broadcastGlobal`/`broadcastAll`/`sendDm`/`sendUserUpdate` → ingress; `forwardToGlobal`/`forwardToStreamerRooms`/`triggerChannelSound`/`synthesizeAndBroadcastTTS` → drop-and-log-once shims (exactly what the old `RemoteChatServer` did; Chat speaks `tts` and plays sounds itself).
- `server/chat/chat-delivery.js:140-207` — presence poller moved from `RemoteChatServer` (`init`/`close`/`slowModeByStream`/`getTotalConnections`/`getStreamViewerCount`/`getConnectedUserIp`/`findClientByAnonId`/`_observeDb` cache hints).
- `server/chat/chat-delivery.js:210-289` — Live's own address/anon helpers moved (`normalizeIp`, `getClientIp`, `getAnonIdForIp`, `getAnonIdForConnection`, `resolveAnon`).
- `server/index.js:102` (require), `:201` (`close`), `:823,:930` (health/clients), `:862` (drop `chat` from WS gauges), `:1142` (ban-check IP), `:1151` (**local `/ws/chat` upgrade branch deleted**), `:1230` (`init`), `:1235` (news gate), `:1463` (`announce({db})`).
- `server/vibe-coding/publish-server.js:19` — constructor no longer takes the chat server (index.js:243 passes `db`).

**Pushes repointed**
- `server/integrations/chat-relay-service.js:435,438,440,444,456`; `robotstreamer-service.js:691,741,744,746,750`; `powerchat-webhook.js:96,98-99,131,143,152,163,174,190,304,314`; `poster.js:88-90`; `media-queue.js:899-901`; `clip-notify.js:89`; `recap.js:205`; `tips/delivery-routes.js:139-140,144,158`; `monetization/routes.js:114,117,122,154,159`; `monetization/alerts.js:18-22` (new `playAlertSound(streamerId, streamId, kind)` — the `chatServer` arg is gone).
- `server/admin/mod-routes.js:112,236,258,420-424,488-492,700-701,801,823` (the `chatServer.clients` global-delete loop at :491 became `delivery.broadcastGlobal`).

**Presence/reads repointed**
- `server/index.js:823,930`; `server/diagnostics.js:49` (+ `admin/routes.js:67` now passes `chatDelivery`); `server/ai/context/index.js:230`; `server/ai/viewers/scheduler.js:53`; `server/admin/mod-routes.js:112,236,258,801,823`; `server/streaming/call-server.js:24,242,266,404`; `server/chat/live-context-routes.js:273,279`.
- `server/streaming/routes.js:37` — dead require removed. `server/chat/deploy-notice.js:33-37,87,122-126` — local socket push + `replayTo` removed (they were unreachable in chat mode; Chat renders the card from `live.release.deployed`).
- Comments corrected: `chat-authority.js:8-11`, `arena/voice.js:4`, `news-service.js:9`; docs `AGENTS.md` (WS endpoints + ChatServer pitfall), `docs/architecture.md:137,157`, `docs/chat-system.md:7,26-42`.

**Confirmed before deleting the upgrade path:** nginx `location ^~ /ws/chat { proxy_pass http://127.0.0.1:4400; }` (OpenVibe.Chat `deploy/nginx/openvibe.live-chat.locations.conf`, included in Live's vhost; cutover runbook: Chat serves it since 2026-09-23 02:03 UTC). A stray direct hit now gets `socket.destroy()` instead of the old 503 hint. `dm.js` untouched (still used by index.js:1192/1658 and `streaming/routes.js` call blocking); `chat-

* Unset CHAT_AUTHORITY documented as inert, anon lookup without a stream matches any socket, gauge doc drops chat (review)

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 36830277..192c6d3d 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -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
ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex 1340095f..423d9384 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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 |
ddiff --git a/docs/chat-system.md b/docs/chat-system.md
iindex 13b13ec9..aabd0fa8 100644
---- a/docs/chat-system.md
++++ b/docs/chat-system.md
@@@ -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 per
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.
--- `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
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
ddiff --git a/server/admin/mod-routes.js b/server/admin/mod-routes.js
iindex 17dd687c..88d51a36 100644
---- a/server/admin/mod-routes.js
++++ b/server/admin/mod-routes.js
@@@ -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;
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 5e1df6d7..4cbb2fb5 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -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 });
ddiff --git a/server/ai/context/index.js b/server/ai/context/index.js
iindex 7382dab5..ccecaa1b 100644
---- a/server/ai/context/index.js
++++ b/server/ai/context/index.js
@@@ -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;
}
ddiff --git a/server/ai/viewers/poster.js b/server/ai/viewers/poster.js
iindex c7f7fba5..f8ac7919 100644
---- a/server/ai/viewers/poster.js
++++ b/server/ai/viewers/poster.js
@@@ -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);
ddiff --git a/server/ai/viewers/scheduler.js b/server/ai/viewers/scheduler.js
iindex 381ffacb..b1167eec 100644
---- a/server/ai/viewers/scheduler.js
++++ b/server/ai/viewers/scheduler.js
@@@ -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) {
ddiff --git a/server/arena/voice.js b/server/arena/voice.js
iindex 60f168f8..0b7fc654 100644
---- a/server/arena/voice.js
++++ b/server/arena/voice.js
@@@ -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
ddiff --git a/server/chat/chat-authority.js b/server/chat/chat-authority.js
iindex 3627f8cc..abc5878e 100644
---- a/server/chat/chat-authority.js
++++ b/server/chat/chat-authority.js
@@@ -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(/\/+$/, '');
ddiff --git a/server/chat/chat-delivery.js b/server/chat/chat-delivery.js
iindex c5976a1a..0d5cb6c2 100644
---- a/server/chat/chat-delivery.js
++++ b/server/chat/chat-delivery.js
@@@ -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,
++};
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
ddeleted file mode 100644
iindex 478df179..00000000
---- a/server/chat/chat-server.js
++++ /dev/null
@@@ -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();
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
iindex 386bff3c..1c2c2bc9 100644
---- a/server/chat/deploy-notice.js
++++ b/server/chat/deploy-notice.js
@@@ -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 };
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex c48bf70e..a7edd6aa 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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) => {
ddiff --git a/server/diagnostics.js b/server/diagnostics.js
iindex 254ec53e..d177f5c6 100644
---- a/server/diagnostics.js
++++ b/server/diagnostics.js
@@@ -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()),
ddiff --git a/server/index.js b/server/index.js
iindex 334db31f..2b6c4d6b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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, re
url,
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 connections
try {
-- 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 service
const 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)
ddiff --git a/server/integrations/chat-relay-service.js b/server/integrations/chat-relay-service.js
iindex b98ac8dc..2d85a26e 100644
---- a/server/integrations/chat-relay-service.js
++++ b/server/integrations/chat-relay-service.js
@@@ -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 TTS
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 */ }
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(),
ddiff --git a/server/integrations/powerchat-webhook.js b/server/integrations/powerchat-webhook.js
iindex 7784a916..07e63547 100644
---- a/server/integrations/powerchat-webhook.js
++++ b/server/integrations/powerchat-webhook.js
@@@ -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 {
ddiff --git a/server/integrations/robotstreamer-service.js b/server/integrations/robotstreamer-service.js
iindex 9b994478..b6e1c401 100644
---- a/server/integrations/robotstreamer-service.js
++++ b/server/integrations/robotstreamer-service.js
@@@ -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');
ddiff --git a/server/media-proxy/clip-notify.js b/server/media-proxy/clip-notify.js
iindex beed6759..ae86abe4 100644
---- a/server/media-proxy/clip-notify.js
++++ b/server/media-proxy/clip-notify.js
@@@ -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);
}
ddiff --git a/server/media/media-queue.js b/server/media/media-queue.js
iindex a394e412..428a03d3 100644
---- a/server/media/media-queue.js
++++ b/server/media/media-queue.js
@@@ -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
ddiff --git a/server/monetization/alerts.js b/server/monetization/alerts.js
iindex 80c0f6b1..2faf54c9 100644
---- a/server/monetization/alerts.js
++++ b/server/monetization/alerts.js
@@@ -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 */ }
}
ddiff --git a/server/monetization/routes.js b/server/monetization/routes.js
iindex 6278e055..85eeb0e1 100644
---- a/server/monetization/routes.js
++++ b/server/monetization/routes.js
@@@ -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;
ddiff --git a/server/news/news-service.js b/server/news/news-service.js
iindex c3859e57..dce59a3f 100644
---- a/server/news/news-service.js
++++ b/server/news/news-service.js
@@@ -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).
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex d0a1fea3..e1a8ca00 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -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; }
ddiff --git a/server/streaming/call-server.js b/server/streaming/call-server.js
iindex b7ef25ef..bc67f003 100644
---- a/server/streaming/call-server.js
++++ b/server/streaming/call-server.js
@@@ -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);
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 8ea073f6..d8aac55d 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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');
ddiff --git a/server/tips/delivery-routes.js b/server/tips/delivery-routes.js
iindex 0b23bd52..d2e09984 100644
---- a/server/tips/delivery-routes.js
++++ b/server/tips/delivery-routes.js
@@@ -114,7 +114,6 @@ router.post('/deliveries', guard('live.tips_delivery.write'), express.json({ lim
const 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') {
ddiff --git a/server/vibe-coding/publish-server.js b/server/vibe-coding/publish-server.js
iindex c6f18b9a..3f0aad2f 100644
---- a/server/vibe-coding/publish-server.js
++++ b/server/vibe-coding/publish-server.js
@@@ -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();
ddiff --git a/server/web/observability.js b/server/web/observability.js
iindex 716c79eb..11a591a4 100644
---- a/server/web/observability.js
++++ b/server/web/observability.js
@@@ -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 }) {
ddiff --git a/test/alerts-bridge.test.js b/test/alerts-bridge.test.js
iindex 1b0544e2..9d882126 100644
---- a/test/alerts-bridge.test.js
++++ b/test/alerts-bridge.test.js
@@@ -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.js
const 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');
ddiff --git a/test/ban-cascade.test.js b/test/ban-cascade.test.js
iindex 8347ec0f..1a0861de 100644
---- a/test/ban-cascade.test.js
++++ b/test/ban-cascade.test.js
@@@ -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);
ddiff --git a/test/chat-connection-cap.test.js b/test/chat-connection-cap.test.js
ddeleted file mode 100644
iindex b22a04e1..00000000
---- a/test/chat-connection-cap.test.js
++++ /dev/null
@@@ -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);
--});
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 77e1d6bd..2a3c234b 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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
ddiff --git a/test/chat-ingress.test.js b/test/chat-ingress.test.js
iindex 521f6154..70ac3f03 100644
---- a/test/chat-ingress.test.js
++++ b/test/chat-ingress.test.js
@@@ -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) {
ddiff --git a/test/dm-delivery.test.js b/test/dm-delivery.test.js
iindex 7d20270b..429df5bd 100644
---- a/test/dm-delivery.test.js
++++ b/test/dm-delivery.test.js
@@@ -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);
});
ddiff --git a/test/floating-promise-fixes.test.js b/test/floating-promise-fixes.test.js
iindex 12153d5f..d13260ae 100644
---- a/test/floating-promise-fixes.test.js
++++ b/test/floating-promise-fixes.test.js
@@@ -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');
ddiff --git a/test/release-events.test.js b/test/release-events.test.js
iindex 5e316cb5..d7316019 100644
---- a/test/release-events.test.js
++++ b/test/release-events.test.js
@@@ -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);
ddiff --git a/test/security-redaction.test.js b/test/security-redaction.test.js
iindex 3cd4c71e..54d23057 100644
---- a/test/security-redaction.test.js
++++ b/test/security-redaction.test.js
@@@ -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');
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex e143ffbc..7245c857 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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)',
ddiff --git a/test/staff-capabilities.test.js b/test/staff-capabilities.test.js
iindex 222693b9..eb6dd909 100644
---- a/test/staff-capabilities.test.js
++++ b/test/staff-capabilities.test.js
@@@ -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)"],
ddiff --git a/test/tips-delivery.test.js b/test/tips-delivery.test.js
iindex 16a4dcf6..5af2612c 100644
---- a/test/tips-delivery.test.js
++++ b/test/tips-delivery.test.js
@@@ -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 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'));
ddiff --git a/test/vibe-coding-scope.test.js b/test/vibe-coding-scope.test.js
iindex 725ee6ef..2069c82d 100644
---- a/test/vibe-coding-scope.test.js
++++ b/test/vibe-coding-scope.test.js
@@@ -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(); });